From 5db84c2c8110480014370ca5b5eed3adb81dbb1d Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 3 Jun 2024 12:02:35 +0800 Subject: [PATCH] Update content negotiation processing Use content negotiation for HTTP extender. TODO: need to genericise the content negotiation logic to apply possibly to other extenders. --- packages/core/src/client/index.ts | 15 +- packages/core/src/common/operation.ts | 4 + .../http-resource-server/test/default.test.ts | 1 + packages/extenders/http/src/backend/core.ts | 162 ++++++++++++++++-- .../src/implementation/patch-delta.ts | 5 + .../src/implementation/patch-merge.ts | 5 + 6 files changed, 173 insertions(+), 19 deletions(-) diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index fa00fa2..5530fdc 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -1,7 +1,20 @@ -import {ServiceParams, App as BaseApp, Endpoint, GetEndpointParams, Operation} from '../common'; +import { + ServiceParams, + App as BaseApp, + Endpoint, + GetEndpointParams, + Operation, + Language, + MediaType, + Charset, +} from '../common'; export interface ClientParams { app: App; + // todo only select from app's registered languages/media types/charsets + language?: Language['name']; + mediaType?: MediaType['name']; + charset?: Charset['name']; fetch?: typeof fetch; } diff --git a/packages/core/src/common/operation.ts b/packages/core/src/common/operation.ts index f4969de..5b70775 100644 --- a/packages/core/src/common/operation.ts +++ b/packages/core/src/common/operation.ts @@ -22,11 +22,13 @@ export interface BaseOperationParams< > { name: Name; method?: Method; + headers?: ConstructorParameters[0]; } export interface Operation { name: Params['name']; method: Params['method']; + headers?: Headers; searchParams?: URLSearchParams; search: (...args: ConstructorParameters) => Operation; setBody: (b: unknown) => Operation; @@ -36,6 +38,7 @@ export interface Operation implements Operation { readonly name: Params['name']; readonly method: Params['method']; + readonly headers?: Headers; theSearchParams?: URLSearchParams; // todo add type safety, depend on method when allowing to have body theBody?: unknown; @@ -43,6 +46,7 @@ class OperationInstance): Operation { diff --git a/packages/examples/http-resource-server/test/default.test.ts b/packages/examples/http-resource-server/test/default.test.ts index 2e5bb2b..730bc59 100644 --- a/packages/examples/http-resource-server/test/default.test.ts +++ b/packages/examples/http-resource-server/test/default.test.ts @@ -81,6 +81,7 @@ describe('default', () => { expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); + console.log(responseRaw.headers); }); it('works for items', async () => { diff --git a/packages/extenders/http/src/backend/core.ts b/packages/extenders/http/src/backend/core.ts index 9d06a8b..8389cb3 100644 --- a/packages/extenders/http/src/backend/core.ts +++ b/packages/extenders/http/src/backend/core.ts @@ -1,5 +1,16 @@ import http from 'http'; -import {ErrorResponse, parseToEndpointQueue, ServiceParams, statusCodes} from '@modal-sh/yasumi'; +import { + ErrorResponse, + parseToEndpointQueue, + ServiceParams, + statusCodes, + FALLBACK_LANGUAGE, + Language, + FALLBACK_MEDIA_TYPE, + MediaType, + FALLBACK_CHARSET, + Charset, +} from '@modal-sh/yasumi'; import { Backend as BaseBackend, Server, @@ -14,6 +25,85 @@ declare module '@modal-sh/yasumi/backend' { interface ServerResponseContext extends http.ServerResponse {} } +const isTextMediaType = (mediaType: string) => { + const { type, subtype } = parseMediaType(mediaType); + if (type === 'text') { + return true; + } + + if (type === 'application' && subtype.length > 0) { + const lastSubtype = subtype.at(-1) as string; + return [ + 'json', + 'xml' + ].includes(lastSubtype); + } + + return false; +}; + +const getLanguage = (languageString?: string): Language => { + if (typeof languageString === 'undefined') { + return FALLBACK_LANGUAGE; + } + // TODO negotiator + return FALLBACK_LANGUAGE; +}; + +const getMediaType = (mediaTypeString?: string): MediaType => { + if (typeof mediaTypeString === 'undefined') { + return FALLBACK_MEDIA_TYPE; + } + return FALLBACK_MEDIA_TYPE; +}; + +const getCharset = (mediaTypeString: string, charset?: string): Charset | undefined => { + if (typeof charset === 'undefined') { + return isTextMediaType(mediaTypeString) ? FALLBACK_CHARSET : undefined; + } + return isTextMediaType(mediaTypeString) ? FALLBACK_CHARSET : undefined; +}; + +const parseMediaType = (mediaType: string) => { + const [type, subtypeEntirety] = mediaType.split('/').map((s) => s.trim()); + return { + type, + // application/foo+json + subtype: subtypeEntirety.split('+'), + }; +}; + +const FALLBACK_BINARY_CHARSET = 'application/octet-stream'; + +const parseAcceptString = (acceptString?: string) => { + if (typeof acceptString !== 'string') { + return undefined; + } + const [mediaType = FALLBACK_BINARY_CHARSET, ...acceptParams] = acceptString.split(';'); + const acceptCharset = acceptParams + .map((s) => s.replace(/\s+/g, '')) + .find((p) => p.startsWith('charset=')); + if (typeof acceptCharset !== 'undefined') { + const [, acceptCharsetValue] = acceptCharset.split('='); + return { + mediaType, + charset: acceptCharsetValue, + }; + } + + if (isTextMediaType(mediaType)) { + return { + // TODO validate if valid mediaType + mediaType, + charset: FALLBACK_CHARSET.name, + }; + } + + return { + mediaType, + }; +}; + class ServerInstance implements Server { readonly backend: Backend; private serverInternal?: http.Server; @@ -23,26 +113,55 @@ class ServerInstance implements Server { } private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => { - // const endpoints = this.backend.app.endpoints; - if (typeof req.method === 'undefined') { + const { method: methodRaw, url, headers } = req; + const language = getLanguage(headers['accept-language']); + const parsedAccept = parseAcceptString(headers['accept']); + const mediaType = typeof parsedAccept !== 'undefined' ? getMediaType(parsedAccept.mediaType) : undefined; + const charset = typeof parsedAccept !== 'undefined' && typeof mediaType !== 'undefined' ? getCharset(mediaType.name, charsetString) : undefined; + + if (typeof methodRaw === 'undefined') { res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); res.end(); return; } - if (typeof req.url === 'undefined') { + if (typeof url === 'undefined') { res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); res.end(); return; } - console.log(req.method, req.url); + const method = methodRaw.toUpperCase(); + console.log(method, url); - const endpoints = parseToEndpointQueue(req.url, this.backend.app.endpoints); + const endpoints = parseToEndpointQueue(url, this.backend.app.endpoints); const [endpoint, endpointParams] = endpoints.at(-1) ?? []; + if (typeof endpoint === 'undefined') { + res.writeHead(statusCodes.HTTP_STATUS_NOT_FOUND); + res.end(); + return; + } + const appOperations = Array.from(this.backend.app.operations.values()) - const foundAppOperation = appOperations - .find((op) => op.method === req.method?.toUpperCase()); + const foundAppOperation = appOperations.find((op) => { + const doesMethodMatch = op.method === method; + if (typeof op.headers !== 'undefined') { + const doesHeadersMatch = Array.from(op.headers.entries()).reduce( + (currentHeadersMatch, [headerKey, opHeaderValue]) => ( + // TODO honor content-type matching + currentHeadersMatch && headers[headerKey.toLowerCase()] === opHeaderValue + ), + true, + ); + + return ( + doesMethodMatch + && doesHeadersMatch + ); + } + + return doesMethodMatch; + }); if (typeof foundAppOperation === 'undefined') { res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { @@ -52,7 +171,7 @@ class ServerInstance implements Server { return; } - if (!endpoint?.operations?.has(foundAppOperation.name)) { + if (!endpoint.operations.has(foundAppOperation.name)) { const endpointOperations = Array.from(endpoint?.operations ?? []); res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { 'Allow': endpointOperations @@ -70,13 +189,7 @@ class ServerInstance implements Server { return; } - if (typeof endpoint === 'undefined') { - res.writeHead(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED); - res.end(); - return; - } - - const [, search] = req.url.split('?'); + const [, search] = url.split('?'); // TODO get content negotiation params @@ -98,10 +211,23 @@ class ServerInstance implements Server { // TODO serialize using content-negotiation params const bodyToSerialize = responseSpec.body; + let encoded = Buffer.from(''); + const headers = {} as Record; + if (typeof bodyToSerialize === 'object' && bodyToSerialize !== null) { + let serialized = ''; + if (typeof mediaType !== 'undefined') { + serialized = mediaType.serialize(bodyToSerialize) as string; + headers['Content-Type'] = mediaType.name; + } + if (typeof charset !== 'undefined' && typeof headers['Content-Type'] === 'string') { + encoded = charset.encode(serialized); + headers['Content-Type'] += `;charset=${charset.name}`; + } + } res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code - res.writeHead(responseSpec.statusCode, {}); - res.end(); + res.writeHead(responseSpec.statusCode, headers); + res.end(encoded); } catch (errorResponseSpecRaw) { const responseSpec = errorResponseSpecRaw as ErrorResponse; res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code diff --git a/packages/recipes/resource/src/implementation/patch-delta.ts b/packages/recipes/resource/src/implementation/patch-delta.ts index a5ac069..9c79451 100644 --- a/packages/recipes/resource/src/implementation/patch-delta.ts +++ b/packages/recipes/resource/src/implementation/patch-delta.ts @@ -5,9 +5,14 @@ export const name = 'patchDelta' as const; export const method = 'PATCH' as const; +export const contentType = 'application/json-patch+json' as const; + export const operation = defineOperation({ name, method, + headers: { + 'Content-Type': contentType, + }, }); export const implementation: ImplementationFunction = async (ctx) => { diff --git a/packages/recipes/resource/src/implementation/patch-merge.ts b/packages/recipes/resource/src/implementation/patch-merge.ts index 77eb157..65bc2f2 100644 --- a/packages/recipes/resource/src/implementation/patch-merge.ts +++ b/packages/recipes/resource/src/implementation/patch-merge.ts @@ -5,9 +5,14 @@ export const name = 'patchMerge' as const; export const method = 'PATCH' as const; +export const contentType = 'application/merge-patch+json' as const; + export const operation = defineOperation({ name, method, + headers: { + 'Content-Type': contentType, + }, }); export const implementation: ImplementationFunction = async (ctx) => {