UNPKG

@nymphjs/server

Version:

Nymph.js - REST Server

982 lines (944 loc) 28.9 kB
import type { OptionsJson } from 'body-parser'; import express, { NextFunction, Request, Response } from 'express'; import cookieParser from 'cookie-parser'; import { Nymph, Entity, EntityConflictError, EntityConstructor, EntityInterface, EntityJson, EntityPatch, EntityReference, InvalidParametersError, TilmeldAccessLevels, classNamesToEntityConstructors, Options, Selector, } from '@nymphjs/nymph'; import { EntityInvalidDataError } from '@nymphjs/nymph'; import { statusDescriptions } from './statusDescriptions.js'; type NymphResponse = Response<any, { nymph: Nymph }>; const NOT_FOUND_ERROR = 'Entity is not found.'; export class ForbiddenClassError extends Error { constructor(message: string) { super(message); this.name = 'ForbiddenClassError'; } } /** * A REST server middleware creator for Nymph. * * Written by Hunter Perrin for SciActive. * * @author Hunter Perrin <hperrin@port87.com> * @copyright SciActive Inc * @see http://nymph.io/ */ export function createServer( nymph: Nymph, { jsonOptions = {} }: { jsonOptions?: OptionsJson } = {}, ) { const rest = express(); rest.use(cookieParser()); rest.use(express.json(jsonOptions || {})); function instantiateNymph( _request: Request, response: NymphResponse, next: NextFunction, ) { response.locals.nymph = nymph.clone(); next(); } async function authenticateTilmeld( request: Request, response: NymphResponse, next: NextFunction, ) { if (response.locals.nymph.tilmeld) { response.locals.nymph.tilmeld.request = request; response.locals.nymph.tilmeld.response = response; try { await response.locals.nymph.tilmeld.authenticate( false, request.header('x-tilmeld-token-renewal') === 'off', ); } catch (e: any) { httpError(response, 500, e); return; } } next(); } function unauthenticateTilmeld( _request: Request, response: NymphResponse, next: NextFunction, ) { if (response.locals.nymph.tilmeld) { response.locals.nymph.tilmeld.request = null; response.locals.nymph.tilmeld.response = null; try { response.locals.nymph.tilmeld.clearSession(); } catch (e: any) { httpError(response, 500, e); return; } } next(); } function getActionData(request: Request): { action: string; data: any } { if (request.method === 'GET') { if ( typeof request.query?.action !== 'string' || typeof request.query?.data !== 'string' ) { return { action: '', data: {}, }; } return { action: JSON.parse(request.query.action) ?? '', data: JSON.parse(request.query.data), }; } else { return { action: request.body.action ?? '', data: request.body.data, }; } } // Create a new instance of Nymph for the request/response. rest.use(instantiateNymph); // Authenticate before the request. rest.use(authenticateTilmeld); rest.get('/', async (request, response: NymphResponse) => { try { const { action, data } = getActionData(request); if (['entity', 'entities', 'uid'].indexOf(action) === -1) { httpError(response, 400); return; } if (['entity', 'entities'].indexOf(action) !== -1) { if (!Array.isArray(data)) { httpError(response, 400); return; } const count = data.length; if (count < 1 || typeof data[0] !== 'object') { httpError(response, 400); return; } if (!('class' in data[0])) { httpError(response, 400); return; } let [options, ...selectors] = data as [Options, ...Selector[]]; let EntityClass; try { EntityClass = response.locals.nymph.getEntityClass(data[0].class); if (!EntityClass.restEnabled) { httpError(response, 403); return; } } catch (e: any) { httpError(response, 400, e); return; } options.class = EntityClass; options.source = 'client'; options.skipAc = false; if (options.return === 'object') { options.return = 'entity'; } try { selectors = classNamesToEntityConstructors( response.locals.nymph, selectors, true, ); } catch (e: any) { if (e?.message === 'Not accessible.') { httpError(response, 403); return; } else { httpError(response, 500, e); return; } } let result: | EntityInterface | EntityInterface[] | string | string[] | number | null; try { if (action === 'entity') { result = await response.locals.nymph.getEntity( options, ...selectors, ); } else { result = await response.locals.nymph.getEntities( options, ...selectors, ); } } catch (e: any) { httpError(response, 500, e); return; } if (result == null || (Array.isArray(result) && result.length === 0)) { if ( action === 'entity' || response.locals.nymph.config.emptyListError ) { httpError(response, 404); return; } } response.setHeader('Content-Type', 'application/json'); response.send(JSON.stringify(result)); } else { if (typeof data !== 'string') { httpError(response, 400); return; } if (response.locals.nymph.tilmeld) { if ( !(await response.locals.nymph.tilmeld.checkClientUIDPermissions( data, TilmeldAccessLevels.READ_ACCESS, )) ) { httpError(response, 403); return; } } let result: number | null; try { result = await response.locals.nymph.getUID(data); } catch (e: any) { httpError(response, 500, e); return; } if (result === null) { httpError(response, 404); return; } else if (typeof result !== 'number') { httpError(response, 500); return; } response.setHeader('Content-Type', 'text/plain'); response.send(`${result}`); } } catch (e: any) { httpError(response, 500, e); return; } }); rest.post('/', async (request, response: NymphResponse) => { try { const { action, data: dataConst } = getActionData(request); let data = dataConst; if (['entity', 'entities', 'uid', 'method'].indexOf(action) === -1) { httpError(response, 400); return; } if (['entity', 'entities'].indexOf(action) !== -1) { if (action === 'entity') { data = [data]; } const created = []; let hadSuccess = false; let invalidRequest = false; let conflict = false; let notfound = false; let forbidden = false; let lastException = null; for (let entData of data) { if (entData.guid) { invalidRequest = true; created.push(null); continue; } let entity: EntityInterface; try { entity = await loadEntity(entData, response.locals.nymph); } catch (e: any) { if (e instanceof EntityConflictError) { conflict = true; } else if (e instanceof ForbiddenClassError) { forbidden = true; } else if (e.message === NOT_FOUND_ERROR) { notfound = true; } else if (e instanceof InvalidParametersError) { invalidRequest = true; lastException = e; } else { lastException = e; } created.push(null); continue; } if (!entity) { invalidRequest = true; created.push(null); continue; } try { if (await entity.$save()) { created.push(entity); hadSuccess = true; } else { created.push(false); } } catch (e: any) { if (e instanceof EntityInvalidDataError) { invalidRequest = true; } else { lastException = e; } created.push(null); } } if (!hadSuccess) { if (invalidRequest) { httpError(response, 400, lastException); return; } else if (conflict) { httpError(response, 409); return; } else if (forbidden) { httpError(response, 403); return; } else if (notfound) { httpError(response, 404); return; } else { httpError(response, 500, lastException); return; } } response.status(201); response.setHeader('Content-Type', 'application/json'); if (action === 'entity') { response.send(JSON.stringify(created[0])); } else { response.send(created); } } else if (action === 'method') { if (!Array.isArray(data.params)) { httpError(response, 400); return; } try { const params = referencesToEntities( [...data.params], response.locals.nymph, ); if (data.static) { let EntityClass: EntityConstructor; try { EntityClass = response.locals.nymph.getEntityClass(data.class); if (!EntityClass.restEnabled) { httpError(response, 403); return; } } catch (e: any) { httpError(response, 400); return; } if ( EntityClass.clientEnabledStaticMethods.indexOf(data.method) === -1 ) { httpError(response, 403); return; } if (!(data.method in EntityClass)) { httpError(response, 400); return; } // @ts-ignore Dynamic methods make TypeScript sad. const method: Function = EntityClass[data.method]; if (typeof method !== 'function') { httpError(response, 400); return; } if (data.iterator) { // Ping every 15 seconds to keep connection alive. const interval = setInterval(() => { if (response.headersSent) { response.write('event: ping\n'); response.write(`data: ${new Date().toISOString()}\n\n`); } }, 15000); try { response.set({ 'Cache-Control': 'no-cache', 'Content-Type': 'text/event-stream', Connection: 'keep-alive', }); response.flushHeaders(); const result: | Iterator<any, any, boolean> | AsyncIterator<any, any, boolean> = method.call( EntityClass, ...params, ); let sequence = result; if (result instanceof Promise) { sequence = await result; } let { value, done } = await sequence.next(response.destroyed); while (!done) { response.write('event: next\n'); response.write(`data: ${JSON.stringify(value)}\n\n`); // Wait for an event loop each iteration, to allow data to flush. await new Promise((resolve) => setImmediate(resolve)); ({ value, done } = await sequence.next(response.destroyed)); } clearInterval(interval); response.write('event: finished\n'); response.write('data: \n\n'); response.end(); } catch (e: any) { clearInterval(interval); eventStreamError(response, 500, e); return; } } else { try { const result = method.call(EntityClass, ...params); let ret = result; if (result instanceof Promise) { ret = await result; } response.status(200); response.setHeader('Content-Type', 'application/json'); response.send({ return: ret }); } catch (e: any) { httpError(response, 500, e); return; } } } else { let entity: EntityInterface; try { entity = await loadEntity(data.entity, response.locals.nymph); } catch (e: any) { if (e instanceof EntityConflictError) { httpError(response, 409); } else if (e instanceof ForbiddenClassError) { httpError(response, 403); } else if (e.message === NOT_FOUND_ERROR) { httpError(response, 404, e); } else if (e instanceof InvalidParametersError) { httpError(response, 400, e); } else { httpError(response, 500, e); } return; } if (data.entity.guid && !entity.guid) { httpError(response, 400); return; } if (entity.$getClientEnabledMethods().indexOf(data.method) === -1) { httpError(response, 403); return; } if ( !(data.method in entity) || typeof entity[data.method] !== 'function' ) { httpError(response, 400); return; } try { const result = entity[data.method](...params); let ret = result; if (result instanceof Promise) { ret = await result; } response.status(200); response.setHeader('Content-Type', 'application/json'); if (data.stateless) { response.send({ return: ret }); } else { response.send({ entity: entity, return: ret }); } } catch (e: any) { httpError(response, 500, e); return; } } } catch (e: any) { if (e instanceof ForbiddenClassError) { httpError(response, 403); return; } else { httpError(response, 500, e); return; } } } else { if (typeof data !== 'string') { httpError(response, 400); return; } if (response.locals.nymph.tilmeld) { if ( !(await response.locals.nymph.tilmeld.checkClientUIDPermissions( data, TilmeldAccessLevels.WRITE_ACCESS, )) ) { httpError(response, 403); return; } } let result: number | null; try { result = await response.locals.nymph.newUID(data); } catch (e: any) { httpError(response, 500, e); return; } if (typeof result !== 'number') { httpError(response, 500); return; } response.status(201); response.setHeader('Content-Type', 'text/plain'); response.send(`${result}`); } } catch (e: any) { httpError(response, 500, e); return; } }); rest.put('/', async (request, response: NymphResponse) => { try { const { action, data } = getActionData(request); if (['entity', 'entities', 'uid'].indexOf(action) === -1) { httpError(response, 400); return; } await doPutOrPatch(response, action, data, false); } catch (e: any) { httpError(response, 500, e); return; } }); rest.patch('/', async (request, response: NymphResponse) => { try { const { action, data } = getActionData(request); if (['entity', 'entities'].indexOf(action) === -1) { httpError(response, 400); return; } await doPutOrPatch(response, action, data, true); } catch (e: any) { httpError(response, 500, e); return; } }); async function doPutOrPatch( response: NymphResponse, action: string, data: any, patch: boolean, ) { if (action === 'uid') { if (typeof data.name !== 'string' || typeof data.value !== 'number') { httpError(response, 400); return; } if (response.locals.nymph.tilmeld) { if ( !(await response.locals.nymph.tilmeld.checkClientUIDPermissions( data.name, TilmeldAccessLevels.FULL_ACCESS, )) ) { httpError(response, 403); return; } } let result: boolean; try { result = await response.locals.nymph.setUID(data.name, data.value); } catch (e: any) { httpError(response, 500, e); return; } if (!result) { httpError(response, 500); return; } response.status(200); response.setHeader('Content-Type', 'text/plain'); response.send(`${result}`); } else { if (action === 'entity') { data = [data]; } const saved = []; let hadSuccess = false; let invalidRequest = false; let conflict = false; let forbidden = false; let notfound = false; let lastException = null; for (let entData of data) { if (entData.guid && entData.guid.length != 24) { invalidRequest = true; saved.push(null); continue; } let entity: EntityInterface; try { entity = await loadEntity(entData, response.locals.nymph, patch); } catch (e: any) { if (e instanceof EntityConflictError) { conflict = true; } else if (e instanceof ForbiddenClassError) { forbidden = true; } else if (e.message === NOT_FOUND_ERROR) { notfound = true; } else if (e instanceof InvalidParametersError) { invalidRequest = true; lastException = e; } else { lastException = e; } saved.push(null); continue; } if (!entity) { invalidRequest = true; saved.push(null); continue; } try { if (await entity.$save()) { saved.push(entity); hadSuccess = true; } else { saved.push(false); } } catch (e: any) { if (e instanceof EntityInvalidDataError) { invalidRequest = true; } else { lastException = e; } saved.push(null); } } if (!hadSuccess) { if (invalidRequest) { httpError(response, 400, lastException); } else if (forbidden) { httpError(response, 403); } else if (conflict) { httpError(response, 409); } else if (notfound) { httpError(response, 404); } else { httpError(response, 500, lastException); } return; } response.status(200); response.setHeader('Content-Type', 'application/json'); if (action === 'entity') { response.send(JSON.stringify(saved[0])); } else { response.send(saved); } } } rest.delete('/', async (request, response: NymphResponse) => { try { const { action, data: dataConst } = getActionData(request); let data = dataConst; if (['entity', 'entities', 'uid'].indexOf(action) === -1) { httpError(response, 400); return; } if (['entity', 'entities'].indexOf(action) !== -1) { if (action === 'entity') { data = [data]; } const deleted = []; let failures = false; let invalidRequest = false; let notfound = false; let lastException = null; for (let entData of data) { if (entData.guid && entData.guid.length != 24) { invalidRequest = true; continue; } let EntityClass: EntityConstructor; try { EntityClass = response.locals.nymph.getEntityClass(entData.class); if (!EntityClass.restEnabled) { httpError(response, 403); return; } } catch (e: any) { invalidRequest = true; failures = true; continue; } let entity: EntityInterface | null; try { entity = await response.locals.nymph.getEntity( { class: EntityClass }, { type: '&', guid: entData.guid }, ); } catch (e: any) { lastException = e; failures = true; continue; } if (!entity) { notfound = true; failures = true; continue; } try { if (await entity.$delete()) { deleted.push(entData.guid); } else { failures = true; } } catch (e: any) { lastException = e; failures = true; } } if (deleted.length === 0) { if (invalidRequest || !failures) { httpError(response, 400, lastException); } else if (notfound) { httpError(response, 404); } else { httpError(response, 500, lastException); } return; } response.status(200); response.setHeader('Content-Type', 'application/json'); if (action === 'entity') { response.send(JSON.stringify(deleted[0])); } else { response.send(deleted); } } else { if (typeof data !== 'string') { httpError(response, 400); return; } if (response.locals.nymph.tilmeld) { if ( !(await response.locals.nymph.tilmeld.checkClientUIDPermissions( data, TilmeldAccessLevels.FULL_ACCESS, )) ) { httpError(response, 403); return; } } let result: boolean; try { result = await response.locals.nymph.deleteUID(data); } catch (e: any) { httpError(response, 500, e); return; } if (!result) { httpError(response, 500); return; } response.status(200); response.setHeader('Content-Type', 'application/json'); response.send(JSON.stringify(result)); } } catch (e: any) { httpError(response, 500, e); return; } }); // Unauthenticate after the request. rest.use(unauthenticateTilmeld); async function loadEntity( entityData: EntityJson | EntityPatch, nymph: Nymph, patch = false, allowConflict = false, ): Promise<EntityInterface> { if (entityData.class === 'Entity') { // Don't let clients use the `Entity` class, since it has no validity/AC checks. throw new InvalidParametersError( "Can't use Entity class directly from the front end.", ); } let EntityClass = nymph.getEntityClass(entityData.class); if (!EntityClass.restEnabled) { throw new ForbiddenClassError('Not accessible.'); } let entity: EntityInterface | null; if (entityData.guid) { entity = await nymph.getEntity( { class: EntityClass, source: 'client' }, { type: '&', guid: `${entityData['guid']}`, }, ); if (entity === null) { throw new Error(NOT_FOUND_ERROR); } } else { entity = await EntityClass.factory(); } if (patch) { entity.$jsonAcceptPatch(entityData as EntityPatch, allowConflict); } else { entity.$jsonAcceptData(entityData as EntityJson, allowConflict); } return entity; } /** * Check if an item is a reference, and if it is, convert it to an entity. * * This function will recurse into deeper arrays and objects. * * @param item The item to check. * @returns The item, converted. */ function referencesToEntities(item: any, nymph: Nymph): any { if (item == null) { return item; } else if (Array.isArray(item)) { if (item.length === 3 && item[0] === 'nymph_entity_reference') { try { const EntityClass = nymph.getEntityClass(item[2]); if (!EntityClass.restEnabled) { throw new ForbiddenClassError('Not accessible.'); } return EntityClass.factoryReference(item as EntityReference); } catch (e: any) { return item; } } return item.map((entry) => referencesToEntities(entry, nymph)); } else if (typeof item === 'object' && !(item instanceof Entity)) { // Only do this for non-entity objects. const newItem: { [k: string]: any } = {}; for (let curProperty in item) { newItem[curProperty] = referencesToEntities(item[curProperty], nymph); } return newItem; } return item; } /** * Return the request with an HTTP error response. * * @param res The server response object. * @param defaultStatusCode The HTTP status code to use if none is given in the error object. * @param error An optional error object to report. */ function httpError( res: NymphResponse, defaultStatusCode: number, error?: Error & { status?: number; statusText?: string }, ) { const status = error?.status || defaultStatusCode; const statusText = error?.statusText || (error?.status == null ? statusDescriptions[defaultStatusCode] : error.status in statusDescriptions && statusDescriptions[error.status]) || 'Internal Server Error'; const errorResponse = error ? { textStatus: `${status} ${statusText}`, statusText, message: error.message, error, ...(process.env.NODE_ENV !== 'production' ? { stack: error.stack } : {}), } : { textStatus: `${status} ${statusText}`, statusText, message: statusText, }; if (!res.headersSent) { res.status(status); res.setHeader('Content-Type', 'application/json'); } res.send(errorResponse); } /** * End the event stream with an HTTP error response. * * @param res The server response object. * @param defaultStatusCode The HTTP status code to use if none is given in the error object. * @param error An optional error object to report. */ function eventStreamError( res: NymphResponse, defaultStatusCode: number, error?: Error & { status?: number; statusText?: string }, ) { const status = error?.status || defaultStatusCode; const statusText = error?.statusText || (error?.status == null ? statusDescriptions[defaultStatusCode] : error.status in statusDescriptions && statusDescriptions[error.status]) || 'Internal Server Error'; const errorResponse = error ? { status, textStatus: `${status} ${statusText}`, statusText, message: error.message, error, ...(process.env.NODE_ENV !== 'production' ? { stack: error.stack } : {}), } : { status, textStatus: `${status} ${statusText}`, statusText, message: statusText, }; if (!res.headersSent) { res.status(status); res.setHeader('Content-Type', 'application/json'); res.send(errorResponse); } else { res.write('event: error\n'); res.write(`data: ${JSON.stringify(errorResponse)}\n\n`); res.write('event: finished\n'); res.write('data: \n\n'); res.end(); } } return rest; }