Add custom endpoints support

This commit is contained in:
rijkvanzanten
2020-09-22 15:55:22 -04:00
parent 48fca55651
commit 7caf429d1d
8 changed files with 193 additions and 103 deletions

View File

@@ -38,9 +38,11 @@ import webhooksRouter from './controllers/webhooks';
import notFoundHandler from './controllers/not-found';
import sanitizeQuery from './middleware/sanitize-query';
import { WebhooksService } from './services/webhooks';
import { ExtensionsService } from './services/extensions';
import { InvalidPayloadException } from './exceptions';
import { registerExtensions } from './extensions';
import emitter from './emitter';
validateEnv(['KEY', 'SECRET']);
const app = express();
@@ -123,9 +125,11 @@ const webhooksService = new WebhooksService();
webhooksService.register();
// Register custom hooks / endpoints
const extensionsService = new ExtensionsService();
extensionsService.register(customRouter);
registerExtensions(customRouter);
track('serverStarted');
emitter.emitAsync('server.started')
.catch((err) => logger.warn(err));
export default app;

View File

@@ -1,7 +1,7 @@
import express, { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { RouteNotFoundException } from '../exceptions';
import { ExtensionsService } from '../services/extensions';
import { listExtensions } from '../extensions';
import env from '../env';
const router = Router();
@@ -12,14 +12,13 @@ router.use(express.static(extensionsPath));
router.get(
'/:type',
asyncHandler(async (req, res, next) => {
const service = new ExtensionsService();
const typeAllowList = ['interfaces', 'layouts', 'displays', 'modules'];
if (typeAllowList.includes(req.params.type) === false) {
throw new RouteNotFoundException(req.path);
}
const extensions = await service.listExtensions(req.params.type);
const extensions = await listExtensions(req.params.type);
res.locals.payload = {
data: extensions,

View File

@@ -1,5 +1,8 @@
import { EventEmitter2 } from 'eventemitter2';
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true });
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true, delimiter: '.' });
// No-op function to ensure we never end up with no data
emitter.on('item.*.*.before', input => input);
export default emitter;

95
api/src/extensions.ts Normal file
View File

@@ -0,0 +1,95 @@
import listFolders from './utils/list-folders';
import path from 'path';
import env from './env';
import { ServiceUnavailableException } from './exceptions';
import express, { Router } from 'express';
import emitter from './emitter';
import logger from './logger';
import { HookRegisterFunction, EndpointRegisterFunction } from './types';
import * as exceptions from './exceptions';
import * as services from './services';
import database from './database';
export async function listExtensions(type: string) {
const extensionsPath = env.EXTENSIONS_PATH as string;
const location = path.join(extensionsPath, type);
try {
return await listFolders(location);
} catch (err) {
if (err.code === 'ENOENT') {
throw new ServiceUnavailableException(`Extension folder "extensions/${type}" couldn't be opened`, {
service: 'extensions',
});
}
throw err;
}
}
export async function registerExtensions(router: Router) {
let hooks: string[] = [];
let endpoints: string[] = [];
try {
hooks = await listExtensions('hooks');
registerHooks(hooks);
} catch (err) {
logger.warn(err);
}
try {
endpoints = await listExtensions('endpoints');
registerEndpoints(endpoints, router);
} catch (err) {
logger.warn(err);
}
console.log(hooks, endpoints);
}
function registerHooks(hooks: string[]) {
const extensionsPath = env.EXTENSIONS_PATH as string;
for (const hook of hooks) {
try {
registerHook(hook);
} catch (error) {
logger.warn(`Couldn't register hook "${hook}"`);
logger.info(error);
}
}
function registerHook(hook: string) {
const hookPath = path.resolve(extensionsPath, 'hooks', hook, 'index.js');
const register: HookRegisterFunction = require(hookPath);
const events = register({ services, exceptions, env, database });
for (const [event, handler] of Object.entries(events)) {
emitter.on(event, handler);
}
}
}
function registerEndpoints(endpoints: string[], router: Router) {
const extensionsPath = env.EXTENSIONS_PATH as string;
for (const endpoint of endpoints) {
try {
registerEndpoint(endpoint);
} catch (error) {
logger.warn(`Couldn't register endpoint "${endpoint}"`);
logger.info(error);
}
}
function registerEndpoint(endpoint: string) {
const endpointPath = path.resolve(extensionsPath, 'endpoints', endpoint, 'index.js');
const register: EndpointRegisterFunction = require(endpointPath);
const scopedRouter = express.Router();
router.use(`/${endpoint}/`, scopedRouter);
register(scopedRouter, { services, exceptions, env, database });
}
}

View File

@@ -1,71 +0,0 @@
import listFolders from '../utils/list-folders';
import path from 'path';
import env from '../env';
import { ServiceUnavailableException } from '../exceptions';
import { Router } from 'express';
import emitter from '../emitter';
import logger from '../logger';
import { HookRegisterFunction } from '../types';
export class ExtensionsService {
registeredHooks: Record<string, Function> = {};
async listExtensions(type: string) {
const extensionsPath = env.EXTENSIONS_PATH as string;
const location = path.join(extensionsPath, type);
try {
return await listFolders(location);
} catch (err) {
if (err.code === 'ENOENT') {
throw new ServiceUnavailableException(`Extension folder "extensions/${type}" couldn't be opened`, {
service: 'extensions',
});
}
throw err;
}
}
async register(router: Router) {
let hooks: string[] = [];
let endpoints: string[] = [];
try {
hooks = await this.listExtensions('hooks');
this.registerHooks(hooks);
} catch (err) {
logger.warn(err);
}
try {
endpoints = await this.listExtensions('endpoints');
} catch (err) {
logger.warn(err);
}
console.log(hooks, endpoints);
}
registerHooks(hooks: string[]) {
const extensionsPath = env.EXTENSIONS_PATH as string;
for (const hook of hooks) {
try {
registerHook(hook);
} catch (error) {
logger.warn(`Couldn't register hook "${hook}"`);
logger.info(error);
}
}
function registerHook(hook: string) {
const hookPath = path.resolve(extensionsPath, 'hooks', hook, 'index.js');
const register: HookRegisterFunction = require(hookPath);
const events = register(null);
for (const [event, handler] of Object.entries(events)) {
emitter.on(event, handler);
}
}
}
}

View File

@@ -2,7 +2,6 @@ export * from './activity';
export * from './assets';
export * from './authentication';
export * from './collections';
export * from './extensions';
export * from './fields';
export * from './files';
export * from './folders';

View File

@@ -15,6 +15,7 @@ import {
import Knex from 'knex';
import cache from '../cache';
import emitter from '../emitter';
import logger from '../logger';
import { PayloadService } from './payload';
import { AuthorizationService } from './authorization';
@@ -51,10 +52,31 @@ export class ItemsService implements AbstractService {
knex: trx,
});
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: trx,
});
if (this.collection.startsWith('directus_') === false) {
const customProcessed = await emitter.emitAsync(`item.create.${this.collection}.before`, payloads, {
event: `item.create.${this.collection}.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'create',
payload: payloads,
});
payloads = customProcessed[customProcessed.length - 1];
}
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
knex: trx,
});
payloads = await authorizationService.validatePayload(
'create',
this.collection,
payloads
);
}
payloads = await payloadService.processM2O(payloads);
@@ -70,14 +92,6 @@ export class ItemsService implements AbstractService {
payloadsWithoutAliases
);
if (this.accountability && this.accountability.admin !== true) {
payloads = await authorizationService.validatePayload(
'create',
this.collection,
payloads
);
}
const primaryKeys: PrimaryKey[] = [];
for (const payloadWithoutAlias of payloadsWithoutAliases) {
@@ -151,12 +165,16 @@ export class ItemsService implements AbstractService {
await cache.clear();
}
emitter.emitAsync(`item.create.${this.collection}`, {
collection: this.collection,
item: primaryKeys,
action: 'create',
payload: payloads,
});
if (this.collection.startsWith('directus_') === false) {
emitter.emitAsync(`item.create.${this.collection}`, {
event: `item.create.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: primaryKeys,
action: 'create',
payload: payloads,
}).catch(err => logger.warn(err));
}
return primaryKeys;
});
@@ -221,7 +239,6 @@ export class ItemsService implements AbstractService {
const records = await runAST(ast, { knex: this.knex });
return Array.isArray(key) ? records : records[0];
return [] as Item;
}
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
@@ -241,6 +258,19 @@ export class ItemsService implements AbstractService {
let payload = clone(data);
if (this.collection.startsWith('directus_') === false) {
const customProcessed = await emitter.emitAsync(`item.update.${this.collection}.before`, payload, {
event: `item.update.${this.collection}.before`,
accountability: this.accountability,
collection: this.collection,
item: null,
action: 'update',
payload,
});
payload = customProcessed[customProcessed.length - 1];
}
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
accountability: this.accountability,
@@ -325,11 +355,13 @@ export class ItemsService implements AbstractService {
}
emitter.emitAsync(`item.update.${this.collection}`, {
event: `item.update.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: key,
action: 'update',
payload,
});
}).catch(err => logger.warn(err));
return key;
}
@@ -347,9 +379,13 @@ export class ItemsService implements AbstractService {
for (const single of payloads as Partial<Item>[]) {
let payload = clone(single);
const key = payload[primaryKeyField];
if (!key)
if (!key) {
throw new InvalidPayloadException('Primary key is missing in update payload.');
}
keys.push(key);
await itemsService.update(payload, key);
}
});
@@ -372,6 +408,15 @@ export class ItemsService implements AbstractService {
await authorizationService.checkAccess('delete', this.collection, key);
}
await emitter.emitAsync(`item.delete.${this.collection}.before`, {
event: `item.update.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: keys,
action: 'delete',
payload: null,
});
await this.knex.transaction(async (trx) => {
await trx(this.collection).whereIn(primaryKeyField, keys).delete();
@@ -394,10 +439,13 @@ export class ItemsService implements AbstractService {
}
emitter.emitAsync(`item.delete.${this.collection}`, {
event: `item.delete.${this.collection}`,
accountability: this.accountability,
collection: this.collection,
item: key,
item: keys,
action: 'delete',
});
payload: null,
}).catch(err => logger.warn(err));
return key;
}

View File

@@ -1,3 +1,16 @@
import { ListenerFn } from 'eventemitter2';
import * as services from '../services';
import * as exceptions from '../exceptions';
import env from '../env';
import Knex from 'knex';
import { Router } from 'express';
export type HookRegisterFunction = (context: any) => Record<string, ListenerFn>;
type ExtensionContext = {
services: typeof services,
exceptions: typeof exceptions,
database: Knex,
env: typeof env,
};
export type HookRegisterFunction = (context: ExtensionContext) => Record<string, ListenerFn>;
export type EndpointRegisterFunction = (router: Router, context: ExtensionContext) => void;