From c92f6899b211ea3ad8bdf39d628ecacb4ad3c5db Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 17 Apr 2024 14:01:17 +0800 Subject: [PATCH] Fix root, error handling Ensure root can be called with any method. --- packages/core/src/backend/common.ts | 5 + .../core/src/backend/servers/http/core.ts | 302 ++++++++++++------ .../backend/servers/http/handlers/default.ts | 8 +- .../core/src/backend/servers/http/utils.ts | 2 +- .../file-jsonl/test/index.test.ts | 2 +- .../cms-web-api/bruno/Create Post with ID.bru | 2 +- .../cms-web-api/bruno/Delete Post.bru | 2 +- .../examples/cms-web-api/bruno/Get Root.bru | 11 + .../cms-web-api/bruno/Get Single Post.bru | 2 +- .../cms-web-api/bruno/Modify Post (Delta).bru | 2 +- .../cms-web-api/bruno/Modify Post (Merge).bru | 2 +- .../cms-web-api/bruno/Query Posts.bru | 2 +- .../cms-web-api/bruno/Replace Post.bru | 2 +- 13 files changed, 226 insertions(+), 118 deletions(-) create mode 100644 packages/examples/cms-web-api/bruno/Get Root.bru diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 942e9d6..e3d5618 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -66,3 +66,8 @@ export interface Response { // metadata of the response headers?: Record; } + +export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => { + const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); + return allowedMethods.join(','); +} diff --git a/packages/core/src/backend/servers/http/core.ts b/packages/core/src/backend/servers/http/core.ts index 25194e0..17b312e 100644 --- a/packages/core/src/backend/servers/http/core.ts +++ b/packages/core/src/backend/servers/http/core.ts @@ -4,7 +4,7 @@ import {constants} from 'http2'; import * as v from 'valibot'; import { AllowedMiddlewareSpecification, - BackendState, + BackendState, getAllowString, Middleware, RequestContext, RequestDecorator, @@ -324,10 +324,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr }; const processRequest = (middlewares: AllowedMiddlewareSpecification[]) => async (req: ResourceRequestContext) => { - if (req.url === '/' || req.url === '') { - return handleGetRoot(req, theRes); - } - const { resource } = req; if (typeof resource === 'undefined') { throw new ErrorPlainResponse('resourceNotFound', { @@ -414,21 +410,26 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { serialized = mediaType.serialize(body); } catch (cause) { - res.statusMessage = language.statusMessages['unableToSerializeResponse']?.replace( - /\$RESOURCE/g, - resourceReq.resource.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(language.bodies['unableToSerializeResponse']); + handleError( + new ErrorPlainResponse('unableToSerializeResponse', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + cause, + }) + )(resourceReq, res); return; } try { encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined; } catch (cause) { - res.statusMessage = language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, - resourceReq.resource.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(language.bodies['unableToEncodeResponse']); + handleError( + new ErrorPlainResponse('unableToEncodeResponse', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + cause, + }) + )(resourceReq, res); return; } @@ -469,6 +470,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { serialized = mediaType.serialize(body); } catch (cause) { + // TODO logging res.statusMessage = language.statusMessages['unableToSerializeResponse']; res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); @@ -478,6 +480,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined; } catch (cause) { + // TODO logging res.statusMessage = language.statusMessages['unableToEncodeResponse']; res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); res.end(); @@ -503,15 +506,188 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr res.end(); }; + const handleResourceResponse = (resourceReq: ResourceRequestContext, res: http.ServerResponse) => (middlewareState: Response) => { + const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; + const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; + const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; + + const headers: Record = { + ...( + middlewareState.headers ?? {} + ), + 'Content-Language': language.name, + }; + if (middlewareState instanceof http.ServerResponse) { + // TODO streaming responses + middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); + return; + } + if (middlewareState instanceof PlainResponse) { + let encoded: Buffer | undefined; + if (typeof middlewareState.body !== 'undefined') { + let serialized; + try { + serialized = mediaType.serialize(middlewareState.body); + } catch (cause) { + const headers: Record = { + 'Content-Language': language.name, + }; + if (resourceReq.method === 'POST') { + headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) + .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) + .join(','); + } else if (resourceReq.method === 'PATCH') { + headers['Accept-Patch'] = Array.from(Object.entries(PATCH_CONTENT_MAP_TYPE)) + .filter(([, value]) => Object.keys(resourceReq.resource.state.canPatch).includes(value)) + .map(([contentType]) => contentType) + .join(','); + } + + handleError(new ErrorPlainResponse('unableToSerializeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + try { + encoded = charset.encode(serialized); + } catch (cause) { + handleError(new ErrorPlainResponse('unableToEncodeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + headers['Content-Type'] = [ + mediaType.name, + `charset=${charset.name}`, + ].join('; '); + } + + const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource.state.itemName) ?? ''; + res.writeHead(middlewareState.statusCode, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + return; + } + + handleError(new ErrorPlainResponse('urlNotFound', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + res, + }))(resourceReq, res); + }; + + const handleResponse = (resourceReq: RequestContext, res: http.ServerResponse) => (middlewareState: Response) => { + if ('resource' in resourceReq && typeof resourceReq.resource !== 'undefined') { + handleResourceResponse(resourceReq as ResourceRequestContext, res)(middlewareState); + return; + } + + const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; + const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; + const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; + + const headers: Record = { + ...( + middlewareState.headers ?? {} + ), + 'Content-Language': language.name, + }; + if (middlewareState instanceof http.ServerResponse) { + // TODO streaming responses + middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); + return; + } + if (middlewareState instanceof PlainResponse) { + let encoded: Buffer | undefined; + if (typeof middlewareState.body !== 'undefined') { + let serialized; + try { + serialized = mediaType.serialize(middlewareState.body); + } catch (cause) { + const headers: Record = { + 'Content-Language': language.name, + }; + if (resourceReq.method === 'POST') { + headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) + .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) + .join(','); + } + + handleError(new ErrorPlainResponse('unableToSerializeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + try { + encoded = charset.encode(serialized); + } catch (cause) { + handleError(new ErrorPlainResponse('unableToEncodeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + headers['Content-Type'] = [ + mediaType.name, + `charset=${charset.name}`, + ].join('; '); + } + + const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; + res.statusMessage = statusMessageKey ?? ''; + res.writeHead(middlewareState.statusCode, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + return; + } + + handleError(new ErrorPlainResponse('urlNotFound', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + res, + }))(resourceReq, res); + }; + const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { - const plainReq = await decorateRequest(reqRaw); // TODO add type safety here, put handleGetRoot as its own middleware as it does not concern over any resource - const language = plainReq.cn.language ?? plainReq.backend.cn.language; - const mediaType = plainReq.cn.mediaType ?? plainReq.backend.cn.mediaType; - const charset = plainReq.cn.charset ?? plainReq.backend.cn.charset; + const plainReq = await decorateRequest(reqRaw); // TODO add type safety here + + if (plainReq.url === '/' || plainReq.url === '') { + const response = await handleGetRoot(plainReq as ResourceRequestContext, theRes); + if (typeof response === 'undefined') { + handleError( + new ErrorPlainResponse('internalServerError', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + }) + )(reqRaw, res); + return; + } + handleResponse(plainReq as ResourceRequestContext, res)(response); + return; + } if (typeof plainReq.resource !== 'undefined') { const resourceReq = plainReq as ResourceRequestContext; - // TODO custom middlewares const effectiveMiddlewares = ( typeof resourceReq.resourceId === 'string' @@ -530,92 +706,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr return; } - // TODO extract to separate function - const headers: Record = { - ...( - middlewareState.headers ?? {} - ), - 'Content-Language': language.name, - }; - if (middlewareState instanceof http.ServerResponse) { - // TODO streaming responses - middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); - return; - } - if (middlewareState instanceof PlainResponse) { - let encoded: Buffer | undefined; - if (typeof middlewareState.body !== 'undefined') { - let serialized; - try { - serialized = mediaType.serialize(middlewareState.body); - } catch (cause) { - const headers: Record = { - 'Content-Language': language.name, - }; - if (resourceReq.method === 'POST') { - headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) - .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) - .join(','); - } else if (resourceReq.method === 'PATCH') { - headers['Accept-Patch'] = Array.from(Object.entries(PATCH_CONTENT_MAP_TYPE)) - .filter(([, value]) => Object.keys(resourceReq.resource.state.canPatch).includes(value)) - .map(([contentType]) => contentType) - .join(','); - } - - handleError(new ErrorPlainResponse('unableToSerializeResponse', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - res, - }))(resourceReq, res); - return; - } - - try { - encoded = charset.encode(serialized); - } catch (cause) { - handleError(new ErrorPlainResponse('unableToEncodeResponse', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - res, - }))(resourceReq, res); - return; - } - - headers['Content-Type'] = [ - mediaType.name, - `charset=${charset.name}`, - ].join('; '); - } - - const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; - res.writeHead(middlewareState.statusCode, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); - return; - } - - if (middlewares.length > 0) { - handleError(new ErrorPlainResponse('methodNotAllowed', { - statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED, - res, - headers: { - Allow: middlewares.map((m) => m.method).join(', '), - }, - }))(resourceReq, res); - return; - } - - handleError(new ErrorPlainResponse('urlNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - res, - }))(resourceReq, res); + handleResponse(resourceReq, res)(middlewareState); return; } @@ -630,7 +721,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr new ErrorPlainResponse('internalServerError', { statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, res, - body: language.bodies['internalServerError'], }) )(reqRaw, res); }; diff --git a/packages/core/src/backend/servers/http/handlers/default.ts b/packages/core/src/backend/servers/http/handlers/default.ts index 5692c90..aa7bff6 100644 --- a/packages/core/src/backend/servers/http/handlers/default.ts +++ b/packages/core/src/backend/servers/http/handlers/default.ts @@ -1,5 +1,5 @@ import {constants} from 'http2'; -import {AllowedMiddlewareSpecification, Middleware} from '../../../common'; +import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '../../../common'; import {LinkMap} from '../utils'; import {PlainResponse, ErrorPlainResponse} from '../response'; import {getAcceptPatchString, getAcceptPostString} from '../../../../common'; @@ -43,11 +43,12 @@ export const handleGetRoot: Middleware = (req, res) => { export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Middleware => (req, res) => { if (middlewares.length > 0) { - const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); + const allowString = getAllowString(middlewares); const headers: Record = { - 'Allow': allowedMethods.join(', '), + 'Allow': getAllowString(middlewares), }; + const allowedMethods = allowString.split(','); if (allowedMethods.includes('POST')) { headers['Accept-Post'] = getAcceptPostString(req.backend.app.mediaTypes); } @@ -60,6 +61,7 @@ export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Mi headers['Accept-Patch'] = getAcceptPatchString(req.resource.state.canPatch); } } + return new PlainResponse({ headers, statusMessage: 'provideOptions', diff --git a/packages/core/src/backend/servers/http/utils.ts b/packages/core/src/backend/servers/http/utils.ts index c3cef82..cad8657 100644 --- a/packages/core/src/backend/servers/http/utils.ts +++ b/packages/core/src/backend/servers/http/utils.ts @@ -39,7 +39,7 @@ export class LinkMap extends Set { const params = Object.entries(e.params); return [ - `<${encodeURIComponent(e.url)}>`, + `<${e.url}>`, ...params.map(([key, value]) => `${encodeURIComponent(key)}="${encodeURIComponent(value)}"`) ].join(';') }).join(','); diff --git a/packages/data-sources/file-jsonl/test/index.test.ts b/packages/data-sources/file-jsonl/test/index.test.ts index 1b861d6..89a0ba6 100644 --- a/packages/data-sources/file-jsonl/test/index.test.ts +++ b/packages/data-sources/file-jsonl/test/index.test.ts @@ -139,7 +139,7 @@ describe('methods', () => { } ]) ); - expect(newItem).toEqual(data); + expect(newItem).toEqual({ id: 0, ...data}); }); }); diff --git a/packages/examples/cms-web-api/bruno/Create Post with ID.bru b/packages/examples/cms-web-api/bruno/Create Post with ID.bru index 9c5c5b8..9dbe97e 100644 --- a/packages/examples/cms-web-api/bruno/Create Post with ID.bru +++ b/packages/examples/cms-web-api/bruno/Create Post with ID.bru @@ -1,7 +1,7 @@ meta { name: Create Post with ID type: http - seq: 7 + seq: 8 } put { diff --git a/packages/examples/cms-web-api/bruno/Delete Post.bru b/packages/examples/cms-web-api/bruno/Delete Post.bru index ae405d9..e1d1bf5 100644 --- a/packages/examples/cms-web-api/bruno/Delete Post.bru +++ b/packages/examples/cms-web-api/bruno/Delete Post.bru @@ -1,7 +1,7 @@ meta { name: Delete Post type: http - seq: 8 + seq: 9 } delete { diff --git a/packages/examples/cms-web-api/bruno/Get Root.bru b/packages/examples/cms-web-api/bruno/Get Root.bru new file mode 100644 index 0000000..0bfe252 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Get Root.bru @@ -0,0 +1,11 @@ +meta { + name: Get Root + type: http + seq: 1 +} + +get { + url: http://localhost:6969/api + body: none + auth: none +} diff --git a/packages/examples/cms-web-api/bruno/Get Single Post.bru b/packages/examples/cms-web-api/bruno/Get Single Post.bru index 349f79f..ef36066 100644 --- a/packages/examples/cms-web-api/bruno/Get Single Post.bru +++ b/packages/examples/cms-web-api/bruno/Get Single Post.bru @@ -1,7 +1,7 @@ meta { name: Get Single Post type: http - seq: 3 + seq: 4 } get { diff --git a/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru b/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru index 09415ec..329a874 100644 --- a/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru +++ b/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru @@ -1,7 +1,7 @@ meta { name: Modify Post (Delta) type: http - seq: 5 + seq: 6 } patch { diff --git a/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru b/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru index 9ca2876..e431c77 100644 --- a/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru +++ b/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru @@ -1,7 +1,7 @@ meta { name: Modify Post (Merge) type: http - seq: 4 + seq: 5 } patch { diff --git a/packages/examples/cms-web-api/bruno/Query Posts.bru b/packages/examples/cms-web-api/bruno/Query Posts.bru index 1c1f8c3..da2b4aa 100644 --- a/packages/examples/cms-web-api/bruno/Query Posts.bru +++ b/packages/examples/cms-web-api/bruno/Query Posts.bru @@ -1,7 +1,7 @@ meta { name: Query Posts type: http - seq: 1 + seq: 3 } get { diff --git a/packages/examples/cms-web-api/bruno/Replace Post.bru b/packages/examples/cms-web-api/bruno/Replace Post.bru index aef447e..d4340b6 100644 --- a/packages/examples/cms-web-api/bruno/Replace Post.bru +++ b/packages/examples/cms-web-api/bruno/Replace Post.bru @@ -1,7 +1,7 @@ meta { name: Replace Post type: http - seq: 6 + seq: 7 } put {