From 0ee4d502ccaa9271fb2ca03384816b2cb65e80c4 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 13 Mar 2024 16:07:25 +0800 Subject: [PATCH] Add content negotiation utilities Add content negotiation for encodings. --- examples/basic/server.ts | 8 +- src/core.ts | 93 +++++++++-- src/data-sources/file-jsonl.ts | 12 +- src/encodings/index.ts | 6 + src/encodings/utf-8.ts | 3 + src/handlers.ts | 274 ++++++++------------------------- src/index.ts | 1 + src/utils.ts | 33 +++- 8 files changed, 197 insertions(+), 233 deletions(-) create mode 100644 src/encodings/index.ts create mode 100644 src/encodings/utf-8.ts diff --git a/examples/basic/server.ts b/examples/basic/server.ts index 2ed28f9..2324482 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -2,7 +2,8 @@ import { application, resource, valibot as v, - serializers + serializers, + encodings, } from '../../src'; import {TEXT_SERIALIZER_PAIR} from './serializers'; import {autoIncrement, dataSource} from './data-source'; @@ -16,6 +17,8 @@ const Piano = resource(v.object( .name('Piano') .id('id', { generationStrategy: autoIncrement, + serialize: (id) => id?.toString() ?? '0', + deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, }); // TODO implement authentication and RBAC on each resource @@ -34,6 +37,8 @@ const User = resource(v.object( .fullText('bio') .id('id', { generationStrategy: autoIncrement, + serialize: (id) => id?.toString() ?? '0', + deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, }); const app = application({ @@ -43,6 +48,7 @@ const app = application({ .contentType('application/json', serializers.applicationJson) .contentType('text/json', serializers.textJson) .contentType('text/plain', TEXT_SERIALIZER_PAIR) + .encoding('utf-8', encodings.utf8) .resource(Piano) .resource(User); diff --git a/src/core.ts b/src/core.ts index 5ec5288..4a978e1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -11,6 +11,9 @@ import { handleGetRoot, handleHasMethodAndUrl, handlePatchItem, } from './handlers'; +import Negotiator from 'negotiator'; +import {getMethod, getUrl} from './utils'; +import {EncodingPair} from './encodings'; export interface DataSource { initialize(): Promise; @@ -46,6 +49,8 @@ export interface ResourceData { schema: T; throws404OnDeletingNotFound: boolean; checksSerializersOnDelete: boolean; + idSerializer: NonNullable; + idDeserializer: NonNullable; } export type Resource = ResourceData & ResourceFactory; @@ -60,6 +65,8 @@ interface GenerationStrategy { interface IdParams { generationStrategy: GenerationStrategy; + serialize?: (id: unknown) => string; + deserialize?: (id: string) => unknown; } export const resource = (schema: T): Resource => { @@ -67,7 +74,9 @@ export const resource = (schema: T): Resource => { let theItemName: string; let theCollectionName: string; let theRouteName: string; - let idGenerationStrategy: GenerationStrategy; + let theIdGenerationStrategy: GenerationStrategy; + let theIdSerializer: IdParams['serialize']; + let theIdDeserializer: IdParams['deserialize']; let throw404OnDeletingNotFound = true; let checkSerializersOnDelete = false; const fullTextAttrs = new Set(); @@ -87,13 +96,21 @@ export const resource = (schema: T): Resource => { get throws404OnDeletingNotFound() { return throw404OnDeletingNotFound; }, + get idSerializer() { + return theIdSerializer; + }, + get idDeserializer() { + return theIdDeserializer; + }, id(newIdAttr: string, params: IdParams) { theIdAttr = newIdAttr; - idGenerationStrategy = params.generationStrategy; + theIdGenerationStrategy = params.generationStrategy; + theIdSerializer = params.serialize; + theIdDeserializer = params.deserialize; return this; }, newId(dataSource: DataSource) { - return idGenerationStrategy(dataSource); + return theIdGenerationStrategy(dataSource); }, fullText(fullTextAttr: string) { if ( @@ -166,9 +183,10 @@ interface HandlerState { handled: boolean; } -interface ApplicationState { +export interface ApplicationState { resources: Set; serializers: Map; + encodings: Map; } interface MiddlewareArgs { @@ -176,6 +194,12 @@ interface MiddlewareArgs { appState: ApplicationState; appParams: ApplicationParams; serverParams: CreateServerParams; + requestBodyEncodingPair: EncodingPair; + responseBodySerializerPair: SerializerPair; + responseMediaType: string; + method: string; + url: string; + query: URLSearchParams; } export interface Middleware { @@ -184,19 +208,25 @@ export interface Middleware { export interface Application { contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this; + encoding(encoding: string, encodingPair: EncodingPair): this; resource(resRaw: Partial): this; createServer(serverParams?: CreateServerParams): http.Server | https.Server; } export const application = (appParams: ApplicationParams): Application => { - const applicationState: ApplicationState = { + const appState: ApplicationState = { resources: new Set(), - serializers: new Map() + serializers: new Map(), + encodings: new Map() }; return { contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { - applicationState.serializers.set(mimeTypePrefix, serializerPair); + appState.serializers.set(mimeTypePrefix, serializerPair); + return this; + }, + encoding(encoding: string, encodingPair: EncodingPair) { + appState.encodings.set(encoding, encodingPair); return this; }, resource(resRaw: Partial) { @@ -205,7 +235,7 @@ export const application = (appParams: ApplicationParams): Application => { if (typeof res.dataSource === 'undefined') { throw new Error(`Resource ${res.itemName} must have a data source.`); } - applicationState.resources.add(res as ResourceWithDataSource); + appState.resources.add(res as ResourceWithDataSource); return this; }, createServer(serverParams = {} as CreateServerParams) { @@ -220,6 +250,43 @@ export const application = (appParams: ApplicationParams): Application => { }); server.on('request', async (req, res) => { + const method = getMethod(req); + const baseUrl = serverParams.baseUrl ?? ''; + const { url, query } = getUrl(req, baseUrl); + + const negotiator = new Negotiator(req); + const availableMediaTypes = Array.from(appState.serializers.keys()); + const mediaType = negotiator.mediaType(availableMediaTypes); + + if (typeof mediaType === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return; + } + + const serializer = appState.serializers.get(mediaType); + if (typeof serializer === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return; + } + + const availableEncodings = Array.from(appState.encodings.keys()); + const encoding = negotiator.encoding(availableEncodings); + + if (typeof encoding === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return; + } + + const encodingPair = appState.encodings.get(encoding); + if (typeof encodingPair === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; + res.end(); + return; + } + // TODO return method not allowed error when operations are not allowed on a resource const middlewareState = await [ handleHasMethodAndUrl, @@ -240,9 +307,15 @@ export const application = (appParams: ApplicationParams): Application => { return middleware({ handlerState: currentHandlerState, - appState: applicationState, + appState, appParams, - serverParams + serverParams, + responseBodySerializerPair: serializer, + responseMediaType: mediaType, + method, + url, + query, + requestBodyEncodingPair: encodingPair, })(req, res); }, Promise.resolve({ diff --git a/src/data-sources/file-jsonl.ts b/src/data-sources/file-jsonl.ts index f85e6d2..8db1c87 100644 --- a/src/data-sources/file-jsonl.ts +++ b/src/data-sources/file-jsonl.ts @@ -7,7 +7,7 @@ export class DataSource> implements DataSourceI data: T[] = []; - constructor(private readonly resource: Resource, private readonly baseDir = '') { + constructor(private readonly resource: Resource, baseDir = '') { this.path = join(baseDir, `${this.resource.collectionName}.jsonl`); } @@ -26,7 +26,7 @@ export class DataSource> implements DataSourceI } async getSingle(id: string) { - const foundData = this.data.find((s) => s[this.resource.idAttr as string].toString() === id); + const foundData = this.data.find((s) => this.resource.idSerializer(s[this.resource.idAttr as string]) === id); if (foundData) { return { @@ -51,7 +51,7 @@ export class DataSource> implements DataSourceI async delete(id: string) { const oldDataLength = this.data.length; - const newData = this.data.filter((s) => !(s[this.resource.idAttr as string].toString() === id)); + const newData = this.data.filter((s) => !(this.resource.idSerializer(s[this.resource.idAttr as string]) === id)); await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); @@ -62,12 +62,12 @@ export class DataSource> implements DataSourceI const existing = await this.getSingle(id); const dataToEmplace = { ...data, - [this.resource.idAttr]: id, // TODO properly serialize it to data source. + [this.resource.idAttr]: this.resource.idDeserializer(id), }; if (existing) { const newData = this.data.map((d) => { - if (d[this.resource.idAttr as string].toString() === id) { + if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) { return dataToEmplace; } @@ -96,7 +96,7 @@ export class DataSource> implements DataSourceI } const newData = this.data.map((d) => { - if (d[this.resource.idAttr as string].toString() === id) { + if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) { return newItem; } diff --git a/src/encodings/index.ts b/src/encodings/index.ts new file mode 100644 index 0000000..c5c3881 --- /dev/null +++ b/src/encodings/index.ts @@ -0,0 +1,6 @@ +export * as utf8 from './utf-8'; + +export interface EncodingPair { + encode: (str: string) => Buffer; + decode: (buf: Buffer) => string; +} diff --git a/src/encodings/utf-8.ts b/src/encodings/utf-8.ts new file mode 100644 index 0000000..238efc3 --- /dev/null +++ b/src/encodings/utf-8.ts @@ -0,0 +1,3 @@ +export const encode = (str: string) => Buffer.from(str, 'utf-8'); + +export const decode = (buf: Buffer) => buf.toString('utf-8'); diff --git a/src/handlers.ts b/src/handlers.ts index b9ca157..2c54435 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,10 +1,10 @@ import { constants } from 'http2'; -import Negotiator from 'negotiator'; import * as v from 'valibot'; import {Middleware} from './core'; -import { getBody, getMethod, getUrl } from './utils'; +import {getBody, getDeserializerObjects, getMethod, getUrl} from './utils'; +import {IncomingMessage, ServerResponse} from 'http'; -export const handleHasMethodAndUrl: Middleware = ({}) => (req, res) => { +export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, res: ServerResponse) => { if (!req.method) { res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { 'Allow': 'HEAD,GET,POST,PUT,PATCH,DELETE' // TODO check with resources on allowed methods @@ -31,56 +31,36 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req, res) => { export const handleGetRoot: Middleware = ({ appState, appParams, - serverParams -}) => (req, res) => { - const method = getMethod(req); + responseBodySerializerPair, + serverParams, + responseMediaType, + method, + url, +}) => (_req: IncomingMessage, res: ServerResponse) => { if (method !== 'GET') { return { handled: false }; } - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); if (url !== '/') { return { handled: false }; } - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - const singleResDatum = { name: appParams.name }; - const theFormatted = theSerializerPair.serialize(singleResDatum); + const theFormatted = responseBodySerializerPair.serialize(singleResDatum); res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': theMediaType, + 'Content-Type': responseMediaType, // we are using custom headers for links because the standard Link header // is referring to the document metadata (e.g. author, next page, etc) // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link 'X-Resource-Link': Array.from(appState.resources) .map((r) => - `<${baseUrl}/${r.routeName}>; name="${r.collectionName}"`, - // TODO add host? + `<${serverParams.baseUrl}/${r.routeName}>; name="${r.collectionName}"`, ) .join(', ') }); @@ -92,8 +72,10 @@ export const handleGetRoot: Middleware = ({ export const handleGetCollection: Middleware = ({ appState, - serverParams -}) => async (req, res) => { + serverParams, + responseBodySerializerPair, + responseMediaType, +}) => async (req: IncomingMessage, res: ServerResponse) => { const method = getMethod(req); if (method !== 'GET') { return { @@ -118,33 +100,13 @@ export const handleGetCollection: Middleware = ({ }; } - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - try { await theResource.dataSource.initialize(); const resData = await theResource.dataSource.getMultiple(); // TODO paginated responses per resource - const theFormatted = theSerializerPair.serialize(resData); + const theFormatted = responseBodySerializerPair.serialize(resData); res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': theMediaType, + 'Content-Type': responseMediaType, 'X-Resource-Total-Item-Count': resData.length }); res.end(theFormatted); @@ -160,8 +122,10 @@ export const handleGetCollection: Middleware = ({ export const handleGetItem: Middleware = ({ appState, - serverParams -}) => async (req, res) => { + serverParams, + responseBodySerializerPair, + responseMediaType, +}) => async (req: IncomingMessage, res: ServerResponse) => { const method = getMethod(req); if (method !== 'GET') { return { @@ -186,32 +150,12 @@ export const handleGetItem: Middleware = ({ }; } - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - try { await theResource.dataSource.initialize(); const singleResDatum = await theResource.dataSource.getSingle(mainResourceId); if (singleResDatum) { - const theFormatted = theSerializerPair.serialize(singleResDatum); - res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': theMediaType}); + const theFormatted = responseBodySerializerPair.serialize(singleResDatum); + res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': responseMediaType}); res.end(theFormatted); return { handled: true @@ -236,18 +180,15 @@ export const handleGetItem: Middleware = ({ export const handleDeleteItem: Middleware = ({ appState, - serverParams -}) => async (req, res) => { - const method = getMethod(req); + method, + url, +}) => async (_req: IncomingMessage, res: ServerResponse) => { if (method !== 'DELETE') { return { handled: false }; } - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); if (mainResourceId === '') { return { @@ -262,28 +203,6 @@ export const handleDeleteItem: Middleware = ({ }; } - if (theResource.checksSerializersOnDelete) { - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - } - try { await theResource.dataSource.initialize(); const response = await theResource.dataSource.delete(mainResourceId); @@ -311,18 +230,17 @@ export const handleDeleteItem: Middleware = ({ export const handlePatchItem: Middleware = ({ appState, - serverParams, -}) => async (req, res) => { - const method = getMethod(req); + method, + url, + responseBodySerializerPair, + responseMediaType, +}) => async (req: IncomingMessage, res: ServerResponse) => { if (method !== 'PATCH') { return { handled: false }; } - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); if (mainResourceId === '') { return { @@ -337,8 +255,8 @@ export const handlePatchItem: Middleware = ({ }; } - const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); - if (typeof theDeserializer === 'undefined') { + const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); + if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; res.end(); return { @@ -346,26 +264,6 @@ export const handlePatchItem: Middleware = ({ }; } - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - await theResource.dataSource.initialize(); const existing = await theResource.dataSource.getSingle(mainResourceId); if (!existing) { @@ -382,7 +280,8 @@ export const handlePatchItem: Middleware = ({ const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema : theResource.schema bodyDeserialized = await getBody( req, - theDeserializer, + requestBodyDeserializerPair, + requestBodyEncodingPair, schema.type === 'object' ? v.partial( schema as v.ObjectSchema, @@ -398,7 +297,7 @@ export const handlePatchItem: Middleware = ({ if (Array.isArray(err.issues)) { // TODO better error reporting, localizable messages - const theFormatted = theSerializerPair.serialize( + const theFormatted = responseBodySerializerPair.serialize( err.issues.map((i) => ( `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` )) @@ -416,9 +315,9 @@ export const handlePatchItem: Middleware = ({ const params = bodyDeserialized as Record; await theResource.dataSource.initialize(); const newObject = await theResource.dataSource.patch(mainResourceId, params); - const theFormatted = theSerializerPair.serialize(newObject); + const theFormatted = responseBodySerializerPair.serialize(newObject); res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': theMediaType, + 'Content-Type': responseMediaType, }); res.end(theFormatted); } catch { @@ -433,18 +332,18 @@ export const handlePatchItem: Middleware = ({ export const handleCreateItem: Middleware = ({ appState, - serverParams -}) => async (req, res) => { - const method = getMethod(req); + serverParams, + method, + url, + responseMediaType, + responseBodySerializerPair, +}) => async (req: IncomingMessage, res: ServerResponse) => { if (method !== 'POST') { return { handled: false }; } - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); if (mainResourceId !== '') { return { @@ -459,8 +358,8 @@ export const handleCreateItem: Middleware = ({ }; } - const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); - if (typeof theDeserializer === 'undefined') { + const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); + if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; res.end(); return { @@ -468,30 +367,9 @@ export const handleCreateItem: Middleware = ({ }; } - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - // TODO determine serializer pair before running the middlewares - let bodyDeserialized: unknown; try { - bodyDeserialized = await getBody(req, theDeserializer, theResource.schema); + bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, theResource.schema); } catch (errRaw) { const err = errRaw as v.ValiError; res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; @@ -499,7 +377,7 @@ export const handleCreateItem: Middleware = ({ if (Array.isArray(err.issues)) { // TODO better error reporting, localizable messages - const theFormatted = theSerializerPair.serialize( + const theFormatted = responseBodySerializerPair.serialize( err.issues.map((i) => ( `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` )) @@ -519,9 +397,9 @@ export const handleCreateItem: Middleware = ({ const params = bodyDeserialized as Record; params[theResource.idAttr] = newId; const newObject = await theResource.dataSource.create(params); - const theFormatted = theSerializerPair.serialize(newObject); + const theFormatted = responseBodySerializerPair.serialize(newObject); res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': theMediaType, + 'Content-Type': responseMediaType, 'Location': `${serverParams.baseUrl}/${theResource.routeName}/${newId}` }); res.end(theFormatted); @@ -537,18 +415,18 @@ export const handleCreateItem: Middleware = ({ export const handleEmplaceItem: Middleware = ({ appState, - serverParams -}) => async (req, res) => { - const method = getMethod(req); + serverParams, + method, + url, + responseBodySerializerPair, + responseMediaType, +}) => async (req: IncomingMessage, res: ServerResponse) => { if (method !== 'PUT') { return { handled: false }; } - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); if (mainResourceId === '') { return { @@ -563,8 +441,8 @@ export const handleEmplaceItem: Middleware = ({ }; } - const theDeserializer = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); - if (typeof theDeserializer === 'undefined') { + const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); + if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; res.end(); return { @@ -572,41 +450,21 @@ export const handleEmplaceItem: Middleware = ({ }; } - const negotiator = new Negotiator(req); - const availableMediaTypes = Array.from(appState.serializers.keys()); - const theMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof theMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - - const theSerializerPair = appState.serializers.get(theMediaType); - if (typeof theSerializerPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return { - handled: true - }; - } - // TODO determine serializer pair before running the middlewares - let bodyDeserialized: unknown; try { const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema : theResource.schema //console.log(schema); bodyDeserialized = await getBody( req, - theDeserializer, + requestBodyDeserializerPair, + requestBodyEncodingPair, schema.type === 'object' ? v.merge([ schema as v.ObjectSchema, v.object({ [theResource.idAttr]: v.transform( v.any(), - input => input.toString(), // TODO serialize/deserialize ID values + input => theResource.idSerializer(input), v.literal(mainResourceId) ) }) @@ -620,7 +478,7 @@ export const handleEmplaceItem: Middleware = ({ if (Array.isArray(err.issues)) { // TODO better error reporting, localizable messages - const theFormatted = theSerializerPair.serialize( + const theFormatted = responseBodySerializerPair.serialize( err.issues.map((i) => ( `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` )) @@ -638,15 +496,15 @@ export const handleEmplaceItem: Middleware = ({ await theResource.dataSource.initialize(); const params = bodyDeserialized as Record; const [newObject, isCreated] = await theResource.dataSource.emplace(mainResourceId, params); - const theFormatted = theSerializerPair.serialize(newObject); + const theFormatted = responseBodySerializerPair.serialize(newObject); if (isCreated) { res.writeHead(constants.HTTP_STATUS_CREATED, { - 'Content-Type': theMediaType, + 'Content-Type': responseMediaType, 'Location': `${serverParams.baseUrl}/${theResource.routeName}/${mainResourceId}` }); } else { res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': theMediaType, + 'Content-Type': responseMediaType, }); } res.end(theFormatted); diff --git a/src/index.ts b/src/index.ts index bb98720..3de64b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './core'; export * as valibot from './validation'; export * as dataSources from './data-sources'; export * as serializers from './serializers'; +export * as encodings from './encodings'; diff --git a/src/utils.ts b/src/utils.ts index 92265c3..e167644 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,20 @@ -import { IncomingMessage } from 'http'; -import { SerializerPair } from './serializers'; -import { BaseSchema, parseAsync } from 'valibot'; +import {IncomingMessage} from 'http'; +import {SerializerPair} from './serializers'; +import {BaseSchema, parseAsync} from 'valibot'; +import { URL } from 'url'; +import {EncodingPair} from './encodings'; +import {ApplicationState} from './core'; -export const getMethod = (req: IncomingMessage) => req.method!.toUpperCase(); +export const getDeserializerObjects = (appState: ApplicationState, req: IncomingMessage) => { + const deserializerPair = appState.serializers.get(req.headers['content-type'] ?? 'application/octet-stream'); + const encodingPair = appState.encodings.get(req.headers['content-encoding'] ?? 'utf-8'); + return { + deserializerPair, + encodingPair, + }; +}; + +export const getMethod = (req: IncomingMessage) => req.method!.trim().toUpperCase(); export const getUrl = (req: IncomingMessage, baseUrl?: string) => { const urlObject = new URL(req.url!, 'http://localhost'); @@ -10,23 +22,28 @@ export const getUrl = (req: IncomingMessage, baseUrl?: string) => { return { url: urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw, - query: urlObject.searchParams + query: urlObject.searchParams, }; } -export const getBody = (req: IncomingMessage, deserializer: SerializerPair, schema: BaseSchema) => { +export const getBody = ( + req: IncomingMessage, + deserializer: SerializerPair, + encodingPair: EncodingPair, + schema: BaseSchema +) => { return new Promise((resolve, reject) => { let body = Buffer.from(''); req.on('data', (chunk) => { body = Buffer.concat([body, chunk]); }); req.on('end', async () => { - const bodyStr = body.toString('utf-8'); // TODO use encoding in request header + const bodyStr = encodingPair.decode(body); try { const bodyDeserialized = await parseAsync( schema, deserializer.deserialize(bodyStr), - { abortEarly: false } + {abortEarly: false}, ); resolve(bodyDeserialized); } catch (err) {