From 83069a3e4a68ce77904ec7cfe468ace5827348a1 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sat, 16 Mar 2024 14:38:07 +0800 Subject: [PATCH] Refactor codebase Respect content negotiation. --- examples/basic/data-source.ts | 4 +- examples/basic/server.ts | 24 +- src/core.ts | 699 +++++++++++++++++----------- src/data-sources/file-jsonl.ts | 20 +- src/encodings/utf-8.ts | 2 + src/handlers.ts | 672 +++++++++++++++++++------- src/languages/en/index.ts | 97 ++++ src/serializers/application/json.ts | 2 + test/e2e/default.test.ts | 115 ++--- 9 files changed, 1109 insertions(+), 526 deletions(-) create mode 100644 src/languages/en/index.ts diff --git a/examples/basic/data-source.ts b/examples/basic/data-source.ts index 7bb202e..9c5ac5f 100644 --- a/examples/basic/data-source.ts +++ b/examples/basic/data-source.ts @@ -9,10 +9,10 @@ export const autoIncrement = async (dataSource: DataSource) => { ); if (Number.isFinite(highestId)) { - return (highestId + 1).toString(); + return (highestId + 1); } - return "1"; + return 1; }; export const dataSource = (resource: Resource) => new dataSources.jsonlFile.DataSource(resource, 'examples/basic'); diff --git a/examples/basic/server.ts b/examples/basic/server.ts index b7007c9..8d2e634 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -20,12 +20,12 @@ const Piano = resource(v.object( serialize: (id) => id?.toString() ?? '0', deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, }) - .allowFetchItem() - .allowFetchCollection() - .allowCreate() - .allowEmplace() - .allowPatch() - .allowDelete(); + .canFetchItem() + .canFetchCollection() + .canCreate() + .canEmplace() + .canPatch() + .canDelete(); const User = resource(v.object( { @@ -56,7 +56,9 @@ const app = application({ .resource(Piano) .resource(User); -const server = app.createServer({ +const backend = app.createBackend(); + +const server = backend.createServer({ baseUrl: '/api' }); @@ -65,8 +67,8 @@ server.listen(3000); setTimeout(() => { // Allow user operations after 5 seconds from startup User - .allowFetchItem() - .allowFetchCollection() - .allowCreate() - .allowPatch(); + .canFetchItem() + .canFetchCollection() + .canCreate() + .canPatch(); }, 5000); diff --git a/src/core.ts b/src/core.ts index 1d98eaa..20fa2f1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -14,9 +14,17 @@ import { import Negotiator from 'negotiator'; import {getMethod, getUrl} from './utils'; import {EncodingPair} from './encodings'; +import * as en from './languages/en'; +import * as utf8 from './encodings/utf-8'; +import * as applicationJson from './serializers/application/json'; + +// TODO define ResourceState +// TODO separate frontend and backend factory methods +// TODO complete content negotiation and default (fallback) messages collection export interface DataSource { initialize(): Promise; + getTotalCount?(): Promise; getMultiple(): Promise; getSingle(id: string): Promise; create(data: Partial): Promise; @@ -30,52 +38,36 @@ export interface ApplicationParams { dataSource?: (resource: Resource) => DataSource; } -interface ResourceFactory { - shouldCheckSerializersOnDelete(b: boolean): this; - shouldThrow404OnDeletingNotFound(b: boolean): this; +export interface Resource { + newId(dataSource: DataSource): string | number | unknown; + schema: T; + state: { + idAttr: string; + itemName?: string; + collectionName?: string; + routeName?: string; + idSerializer: NonNullable; + idDeserializer: NonNullable; + canCreate: boolean; + canFetchCollection: boolean; + canFetchItem: boolean; + canPatch: boolean; + canEmplace: boolean; + canDelete: boolean; + }; id(newIdAttr: string, params: IdParams): this; fullText(fullTextAttr: string): this; name(n: string): this; collection(n: string): this; route(n: string): this; - allowFetchCollection(): this; - allowFetchItem(): this; - allowCreate(): this; - allowPatch(): this; - allowEmplace(): this; - allowDelete(): this; - revokeFetchCollection(): this; - revokeFetchItem(): this; - revokeCreate(): this; - revokePatch(): this; - revokeEmplace(): this; - revokeDelete(): this; + canFetchCollection(b?: boolean): this; + canFetchItem(b?: boolean): this; + canCreate(b?: boolean): this; + canPatch(b?: boolean): this; + canEmplace(b?: boolean): this; + canDelete(b?: boolean): this; } -export interface ResourceData { - idAttr: string; - itemName?: string; - collectionName?: string; - routeName?: string; - newId(dataSource: DataSource): string | number | unknown; - schema: T; - throws404OnDeletingNotFound: boolean; - checksSerializersOnDelete: boolean; - idSerializer: NonNullable; - idDeserializer: NonNullable; -} - -export interface ResourcePermissions { - canCreate: boolean; - canFetchCollection: boolean; - canFetchItem: boolean; - canPatch: boolean; - canEmplace: boolean; - canDelete: boolean; -} - -export type Resource = ResourceData & ResourceFactory & ResourcePermissions; - export interface ResourceWithDataSource extends Resource { dataSource: DataSource; } @@ -93,145 +85,98 @@ interface IdParams { const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { const middlewares = [] as [string, Middleware][]; if (mainResourceId === '') { - if (resource.canFetchCollection) { + if (resource.state.canFetchCollection) { middlewares.push(['GET', handleGetCollection]); } - if (resource.canCreate) { + if (resource.state.canCreate) { middlewares.push(['POST', handleCreateItem]); } return middlewares; } - if (resource.canFetchItem) { + if (resource.state.canFetchItem) { middlewares.push(['GET', handleGetItem]); } - if (resource.canEmplace) { + if (resource.state.canEmplace) { middlewares.push(['PUT', handleEmplaceItem]); } - if (resource.canPatch) { + if (resource.state.canPatch) { middlewares.push(['PATCH', handlePatchItem]); } - if (resource.canDelete) { + if (resource.state.canDelete) { middlewares.push(['DELETE', handleDeleteItem]); } return middlewares; }; +interface ResourceState { + idAttr: string + itemName: string + collectionName: string + routeName: string + idGenerationStrategy: GenerationStrategy + idSerializer: IdParams['serialize'] + idDeserializer: IdParams['deserialize'] + fullTextAttrs: Set; + canCreate: boolean; + canFetchCollection: boolean; + canFetchItem: boolean; + canPatch: boolean; + canEmplace: boolean; + canDelete: boolean; +} + export const resource = (schema: T): Resource => { - let theIdAttr: string; - let theItemName: string; - let theCollectionName: string; - let theRouteName: string; - let theIdGenerationStrategy: GenerationStrategy; - let theIdSerializer: IdParams['serialize']; - let theIdDeserializer: IdParams['deserialize']; - let throw404OnDeletingNotFound = true; - let checkSerializersOnDelete = false; - const fullTextAttrs = new Set(); - let canCreate = false; - let canFetchCollection = false; - let canFetchItem = false; - let canPatch = false; - let canEmplace = false; - let canDelete = false; + const resourceState = { + fullTextAttrs: new Set(), + canCreate: false, + canFetchCollection: false, + canFetchItem: false, + canPatch: false, + canEmplace: false, + canDelete: false, + } as Partial; return { - allowFetchCollection() { - canFetchCollection = true; - return this; - }, - allowFetchItem() { - canFetchItem = true; - return this; - }, - allowCreate() { - canCreate = true; - return this; - }, - allowPatch() { - canPatch = true; - return this; - }, - allowEmplace() { - canEmplace = true; - return this; - }, - allowDelete() { - canDelete = true; - return this; - }, - revokeFetchCollection() { - canFetchCollection = false; - return this; + get state(): ResourceState { + return Object.freeze({ + ...resourceState + }) as unknown as ResourceState; }, - revokeFetchItem() { - canFetchItem = false; + canFetchCollection(b = true) { + resourceState.canFetchCollection = b; return this; }, - revokeCreate() { - canCreate = false; + canFetchItem(b = true) { + resourceState.canFetchItem = b; return this; }, - revokePatch() { - canPatch = false; + canCreate(b = true) { + resourceState.canCreate = b; return this; }, - revokeEmplace() { - canEmplace = false; + canPatch(b = true) { + resourceState.canPatch = b; return this; }, - revokeDelete() { - canDelete = false; - return this; - }, - get canCreate() { - return canCreate; - }, - get canFetchCollection() { - return canFetchCollection; - }, - get canFetchItem() { - return canFetchItem; - }, - get canPatch() { - return canPatch; - }, - get canEmplace() { - return canEmplace; - }, - get canDelete() { - return canDelete; - }, - shouldCheckSerializersOnDelete(b = true) { - checkSerializersOnDelete = b; + canEmplace(b = true) { + resourceState.canEmplace = b; return this; }, - get checksSerializersOnDelete() { - return checkSerializersOnDelete; - }, - shouldThrow404OnDeletingNotFound(b = true) { - throw404OnDeletingNotFound = b; + canDelete(b = true) { + resourceState.canDelete = b; return this; }, - get throws404OnDeletingNotFound() { - return throw404OnDeletingNotFound; - }, - get idSerializer() { - return theIdSerializer; - }, - get idDeserializer() { - return theIdDeserializer; - }, id(newIdAttr: string, params: IdParams) { - theIdAttr = newIdAttr; - theIdGenerationStrategy = params.generationStrategy; - theIdSerializer = params.serialize; - theIdDeserializer = params.deserialize; + resourceState.idAttr = newIdAttr; + resourceState.idGenerationStrategy = params.generationStrategy; + resourceState.idSerializer = params.serialize; + resourceState.idDeserializer = params.deserialize; return this; }, newId(dataSource: DataSource) { - return theIdGenerationStrategy(dataSource); + return resourceState?.idGenerationStrategy?.(dataSource); }, fullText(fullTextAttr: string) { if ( @@ -245,38 +190,38 @@ export const resource = (schema: T): Resource => { ) .entries[fullTextAttr]?.type === 'string' ) { - fullTextAttrs.add(fullTextAttr); + resourceState.fullTextAttrs?.add(fullTextAttr); return this; } throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); }, name(n: string) { - theItemName = n; - theCollectionName = theCollectionName ?? pluralize(theItemName).toLowerCase(); - theRouteName = theRouteName ?? theCollectionName; + resourceState.itemName = n; + resourceState.collectionName = resourceState.collectionName ?? pluralize(n).toLowerCase(); + resourceState.routeName = resourceState.routeName ?? resourceState.collectionName; return this; }, collection(n: string) { - theCollectionName = n; - theRouteName = theRouteName ?? theCollectionName; + resourceState.collectionName = n; + resourceState.routeName = resourceState.routeName ?? n; return this; }, route(n: string) { - theRouteName = n; + resourceState.routeName = n; return this; }, get idAttr() { - return theIdAttr; + return resourceState.idAttr; }, get collectionName() { - return theCollectionName; + return resourceState.collectionName; }, get itemName() { - return theItemName; + return resourceState.itemName; }, get routeName() { - return theRouteName; + return resourceState.routeName; }, get schema() { return schema; @@ -306,18 +251,78 @@ interface HandlerState { export interface ApplicationState { resources: Set; + languages: Map; serializers: Map; encodings: Map; } +type MessageBody = string | string[] | (string | string[])[]; + +export interface MessageCollection { + statusMessages: { + unableToInitializeResourceDataSource(resource: Resource): string; + unableToFetchResourceCollection(resource: Resource): string; + unableToFetchResource(resource: Resource): string; + languageNotAcceptable(): string; + encodingNotAcceptable(): string; + mediaTypeNotAcceptable(): string; + methodNotAllowed(): string; + urlNotFound(): string; + badRequest(): string; + ok(): string; + resourceCollectionFetched(resource: Resource): string; + resourceFetched(resource: Resource): string; + resourceNotFound(resource: Resource): string; + deleteNonExistingResource(resource: Resource): string; + unableToSerializeResponse(): string; + unableToEncodeResponse(): string; + unableToDeleteResource(resource: Resource): string; + resourceDeleted(resource: Resource): string; + unableToDeserializeRequest(): string; + patchNonExistingResource(resource: Resource): string; + unableToPatchResource(resource: Resource): string; + invalidResourcePatch(resource: Resource): string; + invalidResource(resource: Resource): string; + resourcePatched(resource: Resource): string; + resourceCreated(resource: Resource): string; + resourceReplaced(resource: Resource): string; + }, + bodies: { + languageNotAcceptable(): MessageBody; + encodingNotAcceptable(): MessageBody; + mediaTypeNotAcceptable(): MessageBody; + } +} + +export interface BackendState { + fallback: { + language: string; + encoding: string; + serializer: string; + } + errorHeaders: { + language?: string; + encoding?: string; + serializer?: string; + } + showTotalItemCountOnGetCollection: boolean; + throws404OnDeletingNotFound: boolean; + checksSerializersOnDelete: boolean; + showTotalItemCountOnCreateItem: boolean; +} + interface MiddlewareArgs { handlerState: HandlerState; + backendState: BackendState; appState: ApplicationState; appParams: ApplicationParams; serverParams: CreateServerParams; - requestBodyEncodingPair: EncodingPair; - responseBodySerializerPair: SerializerPair; - responseMediaType: string; + responseBodyLanguage: [string, MessageCollection]; + responseBodyEncoding: [string, EncodingPair]; + responseBodyMediaType: [string, SerializerPair]; + errorResponseBodyLanguage: [string, MessageCollection]; + errorResponseBodyEncoding: [string, EncodingPair]; + errorResponseBodyMediaType: [string, SerializerPair]; resource: ResourceWithDataSource; resourceId: string; query: URLSearchParams; @@ -327,20 +332,41 @@ export interface Middleware { (args: MiddlewareArgs): RequestListenerWithReturn> } +export interface Backend { + showTotalItemCountOnGetCollection(b?: boolean): this; + showTotalItemCountOnCreateItem(b?: boolean): this; + checksSerializersOnDelete(b?: boolean): this; + throws404OnDeletingNotFound(b?: boolean): this; + createServer(serverParams?: CreateServerParams): http.Server | https.Server; +} + +export interface Client { + setLanguage(languageCode: string): this; + setEncoding(encoding: string): this; + setContentType(contentType: string): this; +} + export interface Application { contentType(mimeTypePrefix: string, serializerPair: SerializerPair): this; + language(languageCode: string, messageCollection: MessageCollection): this; encoding(encoding: string, encodingPair: EncodingPair): this; resource(resRaw: Partial): this; - createServer(serverParams?: CreateServerParams): http.Server | https.Server; + createBackend(): Backend; + createClient(): Client; } export const application = (appParams: ApplicationParams): Application => { const appState: ApplicationState = { resources: new Set(), + languages: new Map(), serializers: new Map(), - encodings: new Map() + encodings: new Map(), }; + appState.languages.set(en.code, en.messages); + appState.encodings.set(utf8.name, utf8); + appState.serializers.set(applicationJson.name, applicationJson); + return { contentType(mimeTypePrefix: string, serializerPair: SerializerPair) { appState.serializers.set(mimeTypePrefix, serializerPair); @@ -350,148 +376,253 @@ export const application = (appParams: ApplicationParams): Application => { appState.encodings.set(encoding, encodingPair); return this; }, + language(languageCode: string, messageCollection: MessageCollection) { + appState.languages.set(languageCode, messageCollection); + return this; + }, resource(resRaw: Partial) { const res = resRaw as Partial; res.dataSource = res.dataSource ?? appParams.dataSource?.(res as Resource); if (typeof res.dataSource === 'undefined') { - throw new Error(`Resource ${res.itemName} must have a data source.`); + throw new Error(`Resource ${res.state!.itemName} must have a data source.`); } appState.resources.add(res as ResourceWithDataSource); return this; }, - createServer(serverParams = {} as CreateServerParams) { - const server = 'key' in serverParams && 'cert' in serverParams - ? https.createServer({ - key: serverParams.key, - cert: serverParams.cert, - requestTimeout: serverParams.requestTimeout - }) - : http.createServer({ - requestTimeout: serverParams.requestTimeout - }); - - 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 responseMediaType = negotiator.mediaType(availableMediaTypes); - - if (typeof responseMediaType === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return; - } - - const responseBodySerializerPair = appState.serializers.get(responseMediaType); - if (typeof responseBodySerializerPair === '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 requestBodyEncodingPair = appState.encodings.get(encoding); - if (typeof requestBodyEncodingPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; - res.end(); - return; - } - - const middlewareArgs: Omit = { - handlerState: { - handled: false - }, - appState, - appParams, - serverParams, - responseBodySerializerPair, - responseMediaType, - query, - requestBodyEncodingPair, - }; - - const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); - if (methodAndUrl.handled) { - return; + createClient(): Client { + const clientState = { + contentType: applicationJson.name, + encoding: utf8.name, + language: en.code + }; + + return { + setContentType(contentType: string) { + clientState.contentType = contentType; + return this; + }, + setEncoding(encoding: string) { + clientState.encoding = encoding; + return this; + }, + setLanguage(languageCode: string) { + clientState.language = languageCode; + return this; } + } satisfies Client; + }, + createBackend(): Backend { + const backendState: BackendState = { + fallback: { + language: en.code, + encoding: utf8.name, + serializer: applicationJson.name + }, + errorHeaders: { + // undefined follows user accept headers strictly + // + language: undefined, + encoding: undefined, + serializer: undefined, + }, + showTotalItemCountOnGetCollection: false, + showTotalItemCountOnCreateItem: false, + throws404OnDeletingNotFound: false, + checksSerializersOnDelete: false, + + }; + + return { + showTotalItemCountOnGetCollection(b = true) { + backendState.showTotalItemCountOnGetCollection = b; + return this; + }, + showTotalItemCountOnCreateItem(b = true) { + backendState.showTotalItemCountOnCreateItem = b; + return this; + }, + throws404OnDeletingNotFound(b = true) { + backendState.throws404OnDeletingNotFound = b; + return this; + }, + checksSerializersOnDelete(b = true) { + backendState.checksSerializersOnDelete = b; + return this; + }, + createServer(serverParams = {} as CreateServerParams) { + const server = 'key' in serverParams && 'cert' in serverParams + ? https.createServer({ + key: serverParams.key, + cert: serverParams.cert, + requestTimeout: serverParams.requestTimeout + }) + : http.createServer({ + requestTimeout: serverParams.requestTimeout + }); + + 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 languageCandidate = negotiator.language(Array.from(appState.languages.keys())) ?? backendState.fallback.language; + const encodingCandidate = negotiator.encoding(Array.from(appState.encodings.keys())) ?? backendState.fallback.encoding; + const contentTypeCandidate = negotiator.mediaType(Array.from(appState.serializers.keys())) ?? backendState.fallback.serializer; + + const availableLanguages = Array.from(appState.languages.entries()); + const fallbackMessageCollection = en.messages as MessageCollection; + const fallbackSerializerPair = applicationJson as SerializerPair; + const fallbackEncoding = utf8 as EncodingPair; + + const errorLanguageCode = backendState.errorHeaders.language ?? backendState.fallback.language; + const errorMessageCollection = appState.languages.get(errorLanguageCode) ?? fallbackMessageCollection; + + const errorContentType = backendState.errorHeaders.serializer ?? backendState.fallback.serializer; + const errorSerializerPair = appState.serializers.get(errorContentType) ?? fallbackSerializerPair; + + const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding; + const errorEncoding = appState.encodings.get(errorEncodingKey) ?? fallbackEncoding; + + const [currentLanguageCode, currentLanguageMessages] = availableLanguages.find(([code]) => code === languageCandidate) ?? []; + if (typeof currentLanguageCode === 'undefined' || typeof currentLanguageMessages === 'undefined') { + const data = errorMessageCollection.bodies.languageNotAcceptable(); + const responseRaw = errorSerializerPair.serialize(data); + const response = errorEncoding.encode(responseRaw); + res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { + 'Content-Language': errorLanguageCode, + 'Content-Type': errorContentType, + 'Content-Encoding': errorEncodingKey, + }); + res.statusMessage = errorMessageCollection.statusMessages.languageNotAcceptable(); + res.end(response); + return; + } + + const availableMediaTypes = Array.from(appState.serializers.entries()); + const [currentContentTypeMimeType, responseMediaTypeEntry] = availableMediaTypes.find(([key]) => key === contentTypeCandidate) ?? []; + if (typeof currentContentTypeMimeType === 'undefined' || typeof responseMediaTypeEntry === 'undefined') { + const data = errorMessageCollection.bodies.languageNotAcceptable(); + const responseRaw = errorSerializerPair.serialize(data); + const response = errorEncoding.encode(responseRaw); + res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { + 'Content-Language': errorLanguageCode, + 'Content-Type': errorContentType, + 'Content-Encoding': errorEncodingKey, + }); + res.statusMessage = errorMessageCollection.statusMessages.mediaTypeNotAcceptable(); + res.end(response); + return; + } + + const availableEncodings = Array.from(appState.encodings.entries()); + const [currentEncoding, responseBodyEncodingEntry] = availableEncodings.find(([key]) => key === encodingCandidate) ?? []; + if (typeof currentEncoding === 'undefined' || typeof responseBodyEncodingEntry === 'undefined') { + const data = errorMessageCollection.bodies.languageNotAcceptable(); + const responseRaw = errorSerializerPair.serialize(data); + const response = errorEncoding.encode(responseRaw); + res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { + 'Content-Language': errorLanguageCode, + 'Content-Type': errorContentType, + 'Content-Encoding': errorEncodingKey, + }); + res.statusMessage = errorMessageCollection.statusMessages.encodingNotAcceptable(); + res.end(response); + return; + } + + const middlewareArgs: Omit = { + handlerState: { + handled: false + }, + appState, + appParams, + backendState, + serverParams, + query, + responseBodyEncoding: [currentEncoding, responseBodyEncodingEntry], + responseBodyMediaType: [currentContentTypeMimeType, responseMediaTypeEntry], + responseBodyLanguage: [currentLanguageCode, currentLanguageMessages], + errorResponseBodyMediaType: [errorContentType, errorSerializerPair], + errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], + }; + + const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); + if (methodAndUrl.handled) { + return; + } + + if (url === '/') { + const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); + if (middlewareState.handled) { + return; + } - if (url === '/') { - const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); - if (middlewareState.handled) { + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: 'HEAD, GET' + }); + res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed(); + res.end(); + return; + } + + const [, resourceRouteName, resourceId = ''] = url.split('/'); + const resource = Array.from(appState.resources).find((r) => r.state!.routeName === resourceRouteName); + if (typeof resource === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = errorMessageCollection.statusMessages.urlNotFound(); + res.end(); + return; + } + + const middlewares = getAllowedMiddlewares(resource, resourceId); + const middlewareState = await middlewares + .reduce( + async (currentHandlerStatePromise, [middlewareMethod, middleware]) => { + const currentHandlerState = await currentHandlerStatePromise; + if (method !== middlewareMethod) { + return currentHandlerState; + } + + if (currentHandlerState.handled) { + return currentHandlerState; + } + + return middleware({ + ...middlewareArgs, + handlerState: currentHandlerState, + resource, + resourceId: resourceId, + })(req, res); + }, + Promise.resolve({ + handled: false + }) + ); + + if (middlewareState.handled) { + return; + } + + if (middlewares.length > 0) { + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: middlewares.map((m) => m[0]).join(', ') + }); + res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed(); + res.end(); + return; + } + + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = errorMessageCollection.statusMessages.urlNotFound(); + res.end(); return; - } - - res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - Allow: 'HEAD, GET' }); - res.end(); - return; - } - - const [, resourceRouteName, resourceId = ''] = url.split('/'); - const resource = Array.from(appState.resources).find((r) => r.routeName === resourceRouteName); - if (typeof resource === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = 'URL Not Found'; - res.end(); - return; - } - - const middlewares = getAllowedMiddlewares(resource, resourceId); - const middlewareState = await middlewares - .reduce( - async (currentHandlerStatePromise, [middlewareMethod, middleware]) => { - const currentHandlerState = await currentHandlerStatePromise; - if (method !== middlewareMethod) { - return currentHandlerState; - } - - if (currentHandlerState.handled) { - return currentHandlerState; - } - - return middleware({ - ...middlewareArgs, - handlerState: currentHandlerState, - resource, - resourceId: resourceId, - })(req, res); - }, - Promise.resolve({ - handled: false - }) - ); - - if (middlewareState.handled) { - return; - } - if (middlewares.length > 0) { - res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - Allow: middlewares.map((m) => m[0]).join(', ') - }); - res.end(); - return; + return server; } - - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = 'URL Not Found'; - res.end(); - return; - }); - - return server; - } + } satisfies Backend; + }, }; }; diff --git a/src/data-sources/file-jsonl.ts b/src/data-sources/file-jsonl.ts index 4683bde..b94a2ea 100644 --- a/src/data-sources/file-jsonl.ts +++ b/src/data-sources/file-jsonl.ts @@ -8,7 +8,7 @@ export class DataSource> implements DataSourceI data: T[] = []; constructor(private readonly resource: Resource, baseDir = '') { - this.path = join(baseDir, `${this.resource.collectionName}.jsonl`); + this.path = join(baseDir, `${this.resource.state.collectionName}.jsonl`); } async initialize() { @@ -21,12 +21,16 @@ export class DataSource> implements DataSourceI } } + async getTotalCount() { + return this.data.length; + } + async getMultiple() { return [...this.data]; } async getSingle(id: string) { - const foundData = this.data.find((s) => this.resource.idSerializer(s[this.resource.idAttr as string]) === id); + const foundData = this.data.find((s) => this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id); if (foundData) { return { @@ -42,8 +46,8 @@ export class DataSource> implements DataSourceI ...data } as Record; - if (this.resource.idAttr in newData) { - newData[this.resource.idAttr] = this.resource.idDeserializer(newData[this.resource.idAttr] as string); + if (this.resource.state.idAttr in newData) { + newData[this.resource.state.idAttr] = this.resource.state.idDeserializer(newData[this.resource.state.idAttr] as string); } const newCollection = [ @@ -59,7 +63,7 @@ export class DataSource> implements DataSourceI async delete(id: string) { const oldDataLength = this.data.length; - const newData = this.data.filter((s) => !(this.resource.idSerializer(s[this.resource.idAttr as string]) === id)); + const newData = this.data.filter((s) => !(this.resource.state.idSerializer(s[this.resource.state.idAttr as string]) === id)); await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); @@ -70,12 +74,12 @@ export class DataSource> implements DataSourceI const existing = await this.getSingle(id); const dataToEmplace = { ...data, - [this.resource.idAttr]: this.resource.idDeserializer(id), + [this.resource.state.idAttr]: this.resource.state.idDeserializer(id), }; if (existing) { const newData = this.data.map((d) => { - if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) { + if (this.resource.state.idSerializer(d[this.resource.state.idAttr as string]) === id) { return dataToEmplace; } @@ -104,7 +108,7 @@ export class DataSource> implements DataSourceI } const newData = this.data.map((d) => { - if (this.resource.idSerializer(d[this.resource.idAttr as string]) === id) { + if (this.resource.state.idSerializer(d[this.resource.state.idAttr as string]) === id) { return newItem; } diff --git a/src/encodings/utf-8.ts b/src/encodings/utf-8.ts index 238efc3..b314905 100644 --- a/src/encodings/utf-8.ts +++ b/src/encodings/utf-8.ts @@ -1,3 +1,5 @@ export const encode = (str: string) => Buffer.from(str, 'utf-8'); export const decode = (buf: Buffer) => buf.toString('utf-8'); + +export const name = 'utf-8'; diff --git a/src/handlers.ts b/src/handlers.ts index affe3b8..26355ab 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,14 +1,19 @@ import { constants } from 'http2'; import * as v from 'valibot'; import {Middleware} from './core'; -import {getBody, getDeserializerObjects, getMethod, getUrl} from './utils'; +import {getBody, getDeserializerObjects} from './utils'; import {IncomingMessage, ServerResponse} from 'http'; -export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, res: ServerResponse) => { +export const handleHasMethodAndUrl: Middleware = ({ + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], +}) => (req: IncomingMessage, res: ServerResponse) => { if (!req.method) { res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - 'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE' + 'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.methodNotAllowed(); res.end(); return { handled: true @@ -17,6 +22,7 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, if (!req.url) { res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; + res.statusMessage = errorMessageCollection.statusMessages.badRequest(); res.end(); return { handled: true @@ -31,25 +37,68 @@ export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, export const handleGetRoot: Middleware = ({ appState, appParams, - responseBodySerializerPair, serverParams, - responseMediaType, + responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + responseBodyEncoding: [encodingKey, encoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], }) => (_req: IncomingMessage, res: ServerResponse) => { const singleResDatum = { name: appParams.name }; - const theFormatted = responseBodySerializerPair.serialize(singleResDatum); - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': responseMediaType, + + let serialized; + try { + serialized = responseBodySerializerPair.serialize(singleResDatum); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); + res.end(); + return { + handled: true, + }; + } + + let theFormatted; + try { + theFormatted = encoding.encode(serialized); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); + res.end(); + return { + handled: true, + }; + } + + const theHeaders: Record = { + 'Content-Type': responseBodyMediaType, + 'Content-Language': languageCode, + 'Content-Encoding': encodingKey, + }; + + const registeredResources = Array.from(appState.resources); + const availableResources = registeredResources.filter((r) => ( + r.canFetchCollection + || r.canCreate + )); + + if (availableResources.length > 0) { // 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) + theHeaders['X-Resource-Link'] = availableResources .map((r) => - `<${serverParams.baseUrl}/${r.routeName}>; name="${r.collectionName}"`, + `<${serverParams.baseUrl}/${r.state.routeName}>; name="${r.state.collectionName}"`, ) - .join(', ') - }); + .join(', '); + } + res.writeHead(constants.HTTP_STATUS_OK, theHeaders); + res.statusMessage = responseBodyMessageCollection.statusMessages.ok(); res.end(theFormatted); return { handled: true @@ -57,130 +106,242 @@ export const handleGetRoot: Middleware = ({ }; export const handleGetCollection: Middleware = ({ - appState, - serverParams, - responseBodySerializerPair, - responseMediaType, -}) => async (req: IncomingMessage, res: ServerResponse) => { - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); - - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); - if (mainResourceId !== '') { + resource, + responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + responseBodyEncoding: [encodingKey, encoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], + backendState, +}) => async (_req: IncomingMessage, res: ServerResponse) => { + try { + await resource.dataSource.initialize(); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); + res.end(); return { - handled: false - } + handled: true + }; } - const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource === 'undefined') { + let resData: Object[]; + let totalItemCount: number | undefined; + try { + // TODO querying mechanism + resData = await resource.dataSource.getMultiple(); // TODO paginated responses per resource + if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { + totalItemCount = await resource.dataSource.getTotalCount(); + } + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResourceCollection(resource); + res.end(); return { - handled: false + handled: true }; } + let serialized; try { - await theResource.dataSource.initialize(); - // TODO querying mechanism - const resData = await theResource.dataSource.getMultiple(); // TODO paginated responses per resource - const theFormatted = responseBodySerializerPair.serialize(resData); - - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': responseMediaType, - 'X-Resource-Total-Item-Count': resData.length + serialized = responseBodySerializerPair.serialize(resData); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, }); - res.end(theFormatted); + res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); + res.end(); + return { + handled: true, + }; + } + + let theFormatted; + try { + theFormatted = encoding.encode(serialized); } catch { - res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); res.end(); + return { + handled: true, + }; + } + + const headers: Record = { + 'Content-Type': responseBodyMediaType, + 'Content-Language': languageCode, + 'Content-Encoding': encodingKey, + }; + + if (typeof totalItemCount !== 'undefined') { + headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); } + res.writeHead(constants.HTTP_STATUS_OK, headers); + res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCollectionFetched(resource); + res.end(theFormatted); return { handled: true }; }; export const handleGetItem: Middleware = ({ - appState, - serverParams, - responseBodySerializerPair, - responseMediaType, -}) => async (req: IncomingMessage, res: ServerResponse) => { - const method = getMethod(req); - if (method !== 'GET') { + resourceId, + resource, + responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + responseBodyEncoding: [encodingKey, encoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], +}) => async (_req: IncomingMessage, res: ServerResponse) => { + try { + await resource.dataSource.initialize(); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); + res.end(); return { - handled: false + handled: true }; } - const baseUrl = serverParams.baseUrl ?? ''; - const { url } = getUrl(req, baseUrl); - - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); - if (mainResourceId === '') { + let singleResDatum: Object | null = null; + try { + singleResDatum = await resource.dataSource.getSingle(resourceId); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource); + res.end(); return { - handled: false - } + handled: true + }; } - const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource === 'undefined') { + let serialized: string | null; + try { + serialized = singleResDatum === null ? null : responseBodySerializerPair.serialize(singleResDatum); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); + res.end(); return { - handled: false + handled: true, }; } + let theFormatted; try { - await theResource.dataSource.initialize(); - const singleResDatum = await theResource.dataSource.getSingle(mainResourceId); - if (singleResDatum) { - const theFormatted = responseBodySerializerPair.serialize(singleResDatum); - res.writeHead(constants.HTTP_STATUS_OK, {'Content-Type': responseMediaType}); - res.end(theFormatted); - return { - handled: true - }; - } - - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = `${theResource.itemName} Not Found`; + theFormatted = serialized === null ? null : encoding.encode(serialized); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); res.end(); return { - handled: true + handled: true, }; - } catch (err) { - res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.end(); + } + + if (theFormatted) { + res.writeHead(constants.HTTP_STATUS_OK, { + 'Content-Type': responseBodyMediaType, + 'Content-Language': languageCode, + 'Content-Encoding': encodingKey, + }); + res.statusMessage = responseBodyMessageCollection.statusMessages.resourceFetched(resource) + res.end(theFormatted); return { handled: true }; } + + res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.resourceNotFound(resource); + res.end(); + return { + handled: true + }; }; export const handleDeleteItem: Middleware = ({ resource, resourceId, + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], + backendState, }) => async (_req: IncomingMessage, res: ServerResponse) => { try { await resource.dataSource.initialize(); - const response = await resource.dataSource.delete(resourceId); - if (typeof response !== 'undefined' && !response && resource.throws404OnDeletingNotFound) { - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = `${resource.itemName} Not Found`; - } else { - res.statusCode = constants.HTTP_STATUS_NO_CONTENT; - } + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); res.end(); return { handled: true }; + } + + let response; + try { + response = await resource.dataSource.delete(resourceId); } catch { - // TODO error handling - // what if item is already deleted? Should we hide it by returning no content or throw a 404? + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource); + res.end(); + return { + handled: true + }; + } + + const throwOnNotFound = !response && backendState.throws404OnDeletingNotFound; + + res.writeHead( + throwOnNotFound + ? constants.HTTP_STATUS_NOT_FOUND + : constants.HTTP_STATUS_NO_CONTENT, + + throwOnNotFound + ? { + 'Content-Language': errorLanguageCode, + // TODO provide more details + } + : { + 'Content-Language': languageCode, + } + ); + res.statusMessage = ( + throwOnNotFound + ? errorMessageCollection.statusMessages.deleteNonExistingResource(resource) + : responseBodyMessageCollection.statusMessages.resourceDeleted(resource) + ); + + if (throwOnNotFound) { + // TODO provide error message + res.end(); + return { + handled: true + }; } - res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; res.end(); return { handled: true @@ -189,25 +350,59 @@ export const handleDeleteItem: Middleware = ({ export const handlePatchItem: Middleware = ({ appState, - responseBodySerializerPair, - responseMediaType, resource, resourceId, + responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + responseBodyEncoding: [encodingKey, encoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], + errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], + errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], }) => async (req: IncomingMessage, res: ServerResponse) => { const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; + res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest(); + res.end(); + return { + handled: true + }; + } + + try { + await resource.dataSource.initialize(); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); + res.end(); + return { + handled: true + }; + } + + let existing: object | null; + try { + existing = await resource.dataSource.getSingle(resourceId); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(resource); res.end(); return { handled: true }; } - await resource.dataSource.initialize(); - const existing = await resource.dataSource.getSingle(resourceId); if (!existing) { - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = `${resource.itemName} Not Found`; + res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.patchNonExistingResource(resource); res.end(); return { handled: true @@ -231,54 +426,113 @@ export const handlePatchItem: Middleware = ({ ); } catch (errRaw) { const err = errRaw as v.ValiError; - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${resource.itemName}`; - - if (Array.isArray(err.issues)) { - // TODO better error reporting, localizable messages - const theFormatted = responseBodySerializerPair.serialize( - err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )) - ); - res.end(theFormatted); - } else { + const headers: Record = { + 'Content-Language': languageCode, + }; + if (!Array.isArray(err.issues)) { + res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) + res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource); res.end(); + return { + handled: true, + }; } + // TODO better error reporting, localizable messages + // TODO handle error handlers' errors + const serialized = errorSerializerPair.serialize( + err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + ); + const theFormatted = errorEncoding.encode(serialized); + res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { + ...headers, + 'Content-Type': errorMediaType, + 'Content-Encoding': errorEncodingKey, + }) + res.statusMessage = errorMessageCollection.statusMessages.invalidResourcePatch(resource); + res.end(theFormatted); return { - handled: true + handled: true, }; } + const params = bodyDeserialized as Record; + + let newObject: object | null; try { - const params = bodyDeserialized as Record; - await resource.dataSource.initialize(); - const newObject = await resource.dataSource.patch(resourceId, params); - const theFormatted = responseBodySerializerPair.serialize(newObject); - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': responseMediaType, + newObject = await resource.dataSource.patch(resourceId, params); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, }); - res.end(theFormatted); + res.statusMessage = errorMessageCollection.statusMessages.unableToPatchResource(resource); + res.end(); + return { + handled: true, + }; + } + + let serialized; + try { + serialized = responseBodySerializerPair.serialize(newObject); } catch { - res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${resource.itemName}`; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); res.end(); + return { + handled: true, + }; } + + let theFormatted; + try { + theFormatted = encoding.encode(serialized); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); + res.end(); + return { + handled: true, + }; + } + + res.writeHead(constants.HTTP_STATUS_OK, { + 'Content-Type': responseBodyMediaType, + 'Content-Language': languageCode, + 'Content-Encoding': encodingKey, + }); + res.statusMessage = responseBodyMessageCollection.statusMessages.resourcePatched(resource); + res.end(theFormatted); return { handled: true }; + + // TODO finish the rest of the handlers!!! }; export const handleCreateItem: Middleware = ({ appState, serverParams, - responseMediaType, - responseBodySerializerPair, + backendState, + responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + responseBodyEncoding: [encodingKey, encoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], + errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], + errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], resource, }) => async (req: IncomingMessage, res: ServerResponse) => { const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; + res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest(); res.end(); return { handled: true @@ -287,43 +541,87 @@ export const handleCreateItem: Middleware = ({ let bodyDeserialized: unknown; try { - bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, resource.schema); + bodyDeserialized = await getBody( + req, + requestBodyDeserializerPair, + requestBodyEncodingPair, + resource.schema + ); } catch (errRaw) { const err = errRaw as v.ValiError; - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${resource.itemName}`; - - if (Array.isArray(err.issues)) { - // TODO better error reporting, localizable messages - const theFormatted = responseBodySerializerPair.serialize( - err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )) - ); - res.end(theFormatted); - } else { + const headers: Record = { + 'Content-Language': errorLanguageCode, + }; + if (!Array.isArray(err.issues)) { + res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) + res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); res.end(); + return { + handled: true, + }; } + // TODO better error reporting, localizable messages + // TODO handle error handlers' errors + const serialized = errorSerializerPair.serialize( + err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + ); + const theFormatted = errorEncoding.encode(serialized); + res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { + ...headers, + 'Content-Type': errorMediaType, + 'Content-Encoding': errorEncodingKey, + }) + res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); + res.end(theFormatted); return { - handled: true + handled: true, }; } try { await resource.dataSource.initialize(); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); + res.end(); + return { + handled: true + }; + } + + try { + // TODO error handling for each process const newId = await resource.newId(resource.dataSource); const params = bodyDeserialized as Record; - params[resource.idAttr] = newId; + params[resource.state.idAttr] = newId; const newObject = await resource.dataSource.create(params); - const theFormatted = responseBodySerializerPair.serialize(newObject); - res.writeHead(constants.HTTP_STATUS_CREATED, { - 'Content-Type': responseMediaType, - 'Location': `${serverParams.baseUrl}/${resource.routeName}/${newId}` - }); + let totalItemCount: number | undefined; + if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { + totalItemCount = await resource.dataSource.getTotalCount(); + } + const headers: Record = { + 'Content-Type': responseBodyMediaType, + 'Content-Language': languageCode, + 'Content-Encoding': encodingKey, + 'Location': `${serverParams.baseUrl}/${resource.state.routeName}/${newId}` + }; + if (typeof totalItemCount !== 'undefined') { + headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); + } + const serialized = responseBodySerializerPair.serialize(newObject); + const theFormatted = encoding.encode(serialized); + res.writeHead(constants.HTTP_STATUS_CREATED, headers); + res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCreated(resource); res.end(theFormatted); } catch { - res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${resource.itemName}`; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }) + res.statusMessage = `Could Not Return ${resource.state.itemName}`; res.end(); } return { @@ -334,14 +632,22 @@ export const handleCreateItem: Middleware = ({ export const handleEmplaceItem: Middleware = ({ appState, serverParams, - responseBodySerializerPair, - responseMediaType, + responseBodyMediaType: [responseBodyMediaType, responseBodySerializerPair], + responseBodyLanguage: [languageCode, responseBodyMessageCollection], + responseBodyEncoding: [encodingKey, encoding], + errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], + errorResponseBodyMediaType: [errorMediaType, errorSerializerPair], + errorResponseBodyEncoding: [errorEncodingKey, errorEncoding], resource, resourceId, + backendState, }) => async (req: IncomingMessage, res: ServerResponse) => { const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { - res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; + res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToDeserializeRequest(); res.end(); return { handled: true @@ -351,7 +657,6 @@ export const handleEmplaceItem: Middleware = ({ let bodyDeserialized: unknown; try { const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema : resource.schema - //console.log(schema); bodyDeserialized = await getBody( req, requestBodyDeserializerPair, @@ -360,9 +665,9 @@ export const handleEmplaceItem: Middleware = ({ ? v.merge([ schema as v.ObjectSchema, v.object({ - [resource.idAttr]: v.transform( + [resource.state.idAttr]: v.transform( v.any(), - input => resource.idSerializer(input), + input => resource.state.idSerializer(input), v.literal(resourceId) ) }) @@ -371,44 +676,81 @@ export const handleEmplaceItem: Middleware = ({ ); } catch (errRaw) { const err = errRaw as v.ValiError; - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${resource.itemName}`; - - if (Array.isArray(err.issues)) { - // TODO better error reporting, localizable messages - const theFormatted = responseBodySerializerPair.serialize( - err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )) - ); - res.end(theFormatted); - } else { + const headers: Record = { + 'Content-Language': errorLanguageCode, + }; + if (!Array.isArray(err.issues)) { + res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) + res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); res.end(); + return { + handled: true, + }; } + // TODO better error reporting, localizable messages + // TODO handle error handlers' errors + const serialized = errorSerializerPair.serialize( + err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + ); + const theFormatted = errorEncoding.encode(serialized); + res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { + ...headers, + 'Content-Type': errorMediaType, + 'Content-Encoding': errorEncodingKey, + }) + res.statusMessage = errorMessageCollection.statusMessages.invalidResource(resource); + res.end(theFormatted); return { - handled: true + handled: true, }; } try { await resource.dataSource.initialize(); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToInitializeResourceDataSource(resource); + res.end(); + return { + handled: true + }; + } + + try { + // TODO error handling for each process const params = bodyDeserialized as Record; const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); - const theFormatted = responseBodySerializerPair.serialize(newObject); + const serialized = responseBodySerializerPair.serialize(newObject); + const theFormatted = encoding.encode(serialized); + const headers: Record = { + 'Content-Type': responseBodyMediaType, + 'Content-Language': languageCode, + 'Content-Encoding': encodingKey, + }; + let totalItemCount: number | undefined; + if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { + totalItemCount = await resource.dataSource.getTotalCount(); + } if (isCreated) { - res.writeHead(constants.HTTP_STATUS_CREATED, { - 'Content-Type': responseMediaType, - 'Location': `${serverParams.baseUrl}/${resource.routeName}/${resourceId}` - }); - } else { - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': responseMediaType, - }); + headers['Location'] = `${serverParams.baseUrl}/${resource.state.routeName}/${resourceId}`; + if (typeof totalItemCount !== 'undefined') { + headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); + } } + res.writeHead(isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, headers); + res.statusMessage = ( + isCreated + ? responseBodyMessageCollection.statusMessages.resourceCreated(resource) + : responseBodyMessageCollection.statusMessages.resourceReplaced(resource) + ); res.end(theFormatted); } catch { res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${resource.itemName}`; + res.statusMessage = `Could Not Return ${resource.state.itemName}`; res.end(); } return { diff --git a/src/languages/en/index.ts b/src/languages/en/index.ts new file mode 100644 index 0000000..dc13506 --- /dev/null +++ b/src/languages/en/index.ts @@ -0,0 +1,97 @@ +import {MessageCollection, Resource} from '../../core'; + +export const messages: MessageCollection = { + statusMessages: { + unableToSerializeResponse(): string { + return 'Unable To Serialize Response'; + }, + unableToEncodeResponse(): string { + return 'Unable To Encode Response'; + }, + unableToInitializeResourceDataSource(resource: Resource): string { + return `Unable To Initialize ${resource.state.itemName} Data Source`; + }, + unableToFetchResourceCollection(resource: Resource): string { + return `Unable To Fetch ${resource.state.itemName} Collection`; + }, + unableToFetchResource(resource: Resource): string { + return `Unable To Fetch ${resource.state.itemName}`; + }, + unableToDeleteResource(resource: Resource): string { + return `Unable To Delete ${resource.state.itemName}`; + }, + languageNotAcceptable(): string { + return 'Language Not Acceptable'; + }, + encodingNotAcceptable(): string { + return 'Encoding Not Acceptable'; + }, + mediaTypeNotAcceptable(): string { + return 'Media Type Not Acceptable'; + }, + methodNotAllowed(): string { + return 'Method Not Allowed'; + }, + urlNotFound(): string { + return 'URL Not Found'; + }, + badRequest(): string { + return 'Bad Request'; + }, + ok(): string { + return 'OK'; + }, + resourceCollectionFetched(resource: Resource): string { + return `${resource.state.itemName} Collection Fetched`; + }, + resourceFetched(resource: Resource): string { + return `${resource.state.itemName} Fetched`; + }, + resourceNotFound(resource: Resource): string { + return `${resource.state.itemName} Not Found`; + }, + deleteNonExistingResource(resource: Resource): string { + return `Delete Non-Existing ${resource.state.itemName}`; + }, + resourceDeleted(resource: Resource): string { + return `${resource.state.itemName} Deleted`; + }, + unableToDeserializeRequest(): string { + return 'Unable To Deserialize Request'; + }, + patchNonExistingResource(resource: Resource): string { + return `Patch Non-Existing ${resource.state.itemName}`; + }, + unableToPatchResource(resource: Resource): string { + return `Unable To Patch ${resource.state.itemName}`; + }, + invalidResourcePatch(resource: Resource): string { + return `Invalid ${resource.state.itemName} Patch`; + }, + invalidResource(resource: Resource): string { + return `Invalid ${resource.state.itemName}`; + }, + resourcePatched(resource: Resource): string { + return `${resource.state.itemName} Patched`; + }, + resourceCreated(resource: Resource): string { + return `${resource.state.itemName} Created`; + }, + resourceReplaced(resource: Resource): string { + return `${resource.state.itemName} Replaced`; + } + }, + bodies: { + languageNotAcceptable() { + return []; + }, + encodingNotAcceptable() { + return []; + }, + mediaTypeNotAcceptable() { + return [] + } + } +}; + +export const code = 'en'; diff --git a/src/serializers/application/json.ts b/src/serializers/application/json.ts index eed0b6d..cf722d2 100644 --- a/src/serializers/application/json.ts +++ b/src/serializers/application/json.ts @@ -1,3 +1,5 @@ export const serialize = (obj: unknown) => JSON.stringify(obj); export const deserialize = (str: string) => JSON.parse(str); + +export const name = 'application/json'; diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts index de44975..f26b2d2 100644 --- a/test/e2e/default.test.ts +++ b/test/e2e/default.test.ts @@ -96,7 +96,11 @@ describe('yasumi', () => { .encoding(ACCEPT_ENCODING, encodings.utf8) .resource(Piano); - server = app.createServer({ + const backend = app + .createBackend() + .throws404OnDeletingNotFound(); + + server = backend.createServer({ baseUrl: '/api' }); @@ -126,10 +130,16 @@ describe('yasumi', () => { })); describe('serving collections', () => { + beforeEach(() => { + Piano.canFetchCollection(); + }); + + afterEach(() => { + Piano.canFetchCollection(false); + }); + it('returns data', () => { return new Promise((resolve, reject) => { - Piano.allowFetchCollection(); - const req = request( { host: HOST, @@ -143,7 +153,6 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeFetchCollection(); reject(err); }); @@ -159,14 +168,12 @@ describe('yasumi', () => { const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); const resData = JSON.parse(resBufferJson); expect(resData).toEqual([]); - Piano.revokeFetchCollection(); resolve(); }); }, ); req.on('error', (err) => { - Piano.revokeFetchCollection(); reject(err); }); @@ -186,10 +193,16 @@ describe('yasumi', () => { await writeFile(resourcePath, JSON.stringify(data)); }); + beforeEach(() => { + Piano.canFetchItem(); + }); + + afterEach(() => { + Piano.canFetchItem(false); + }); + it('returns data', () => { return new Promise((resolve, reject) => { - Piano.allowFetchItem(); - const req = request( { host: HOST, @@ -203,7 +216,6 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeFetchItem(); reject(err); }); @@ -219,14 +231,12 @@ describe('yasumi', () => { const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); const resData = JSON.parse(resBufferJson); expect(resData).toEqual(data); - Piano.revokeFetchItem(); resolve(); }); }, ); req.on('error', (err) => { - Piano.revokeFetchItem(); reject(err); }); @@ -236,8 +246,6 @@ describe('yasumi', () => { it('throws on item not found', () => { return new Promise((resolve, reject) => { - Piano.allowFetchItem(); - const req = request( { host: HOST, @@ -251,19 +259,16 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeFetchItem(); + Piano.canFetchItem(false); reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - - Piano.revokeFetchItem(); resolve(); }, ); req.on('error', (err) => { - Piano.revokeFetchItem(); reject(err); }); @@ -287,11 +292,17 @@ describe('yasumi', () => { await writeFile(resourcePath, JSON.stringify(data)); }); + beforeEach(() => { + Piano.canCreate(); + }); + + afterEach(() => { + Piano.canCreate(false); + }); + // FIXME ID de/serialization problems it('returns data', () => { return new Promise((resolve, reject) => { - Piano.allowCreate(); - const req = request( { host: HOST, @@ -306,7 +317,6 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeCreate(); reject(err); }); @@ -323,16 +333,15 @@ describe('yasumi', () => { const resData = JSON.parse(resBufferJson); expect(resData).toEqual({ ...newData, - id: '2' + id: 2 }); - Piano.revokeCreate(); + resolve(); }); }, ); req.on('error', (err) => { - Piano.revokeCreate(); reject(err); }); @@ -357,15 +366,21 @@ describe('yasumi', () => { await writeFile(resourcePath, JSON.stringify(data)); }); + beforeEach(() => { + Piano.canPatch(); + }); + + afterEach(() => { + Piano.canPatch(false); + }); + it('returns data', () => { return new Promise((resolve, reject) => { - Piano.allowPatch(); - const req = request( { host: HOST, port: PORT, - path: '/api/pianos/1', + path: `/api/pianos/${data.id}`, method: 'PATCH', headers: { 'Accept': ACCEPT, @@ -375,7 +390,6 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokePatch(); reject(err); }); @@ -394,14 +408,12 @@ describe('yasumi', () => { ...data, ...newData, }); - Piano.revokePatch(); resolve(); }); }, ); req.on('error', (err) => { - Piano.revokePatch(); reject(err); }); @@ -412,8 +424,6 @@ describe('yasumi', () => { it('throws on item to patch not found', () => { return new Promise((resolve, reject) => { - Piano.allowPatch(); - const req = request( { host: HOST, @@ -428,19 +438,15 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokePatch(); reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - - Piano.revokePatch(); resolve(); }, ); req.on('error', (err) => { - Piano.revokePatch(); reject(err); }); @@ -466,16 +472,22 @@ describe('yasumi', () => { await writeFile(resourcePath, JSON.stringify(data)); }); + beforeEach(() => { + Piano.canEmplace(); + }); + + afterEach(() => { + Piano.canEmplace(false); + }); + // FIXME IDs not properly being de/serialized it('returns data for replacement', () => { return new Promise((resolve, reject) => { - Piano.allowEmplace(); - const req = request( { host: HOST, port: PORT, - path: '/api/pianos/1', + path: `/api/pianos/${newData.id}`, method: 'PUT', headers: { 'Accept': ACCEPT, @@ -485,7 +497,6 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeEmplace(); reject(err); }); @@ -501,14 +512,12 @@ describe('yasumi', () => { const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); const resData = JSON.parse(resBufferJson); expect(resData).toEqual(newData); - Piano.revokeEmplace(); resolve(); }); }, ); req.on('error', (err) => { - Piano.revokeEmplace(); reject(err); }); @@ -520,7 +529,6 @@ describe('yasumi', () => { it('returns data for creation', () => { return new Promise((resolve, reject) => { const id = 2; - Piano.allowEmplace(); const req = request( { @@ -536,7 +544,6 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeEmplace(); reject(err); }); @@ -555,14 +562,12 @@ describe('yasumi', () => { ...newData, id, }); - Piano.revokeEmplace(); resolve(); }); }, ); req.on('error', (err) => { - Piano.revokeEmplace(); reject(err); }); @@ -586,15 +591,21 @@ describe('yasumi', () => { await writeFile(resourcePath, JSON.stringify(data)); }); + beforeEach(() => { + Piano.canDelete(); + }); + + afterEach(() => { + Piano.canDelete(false); + }); + it('returns data', () => { return new Promise((resolve, reject) => { - Piano.allowDelete(); - const req = request( { host: HOST, port: PORT, - path: '/api/pianos/1', + path: `/api/pianos/${data.id}`, method: 'DELETE', headers: { 'Accept': ACCEPT, @@ -603,18 +614,15 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeDelete(); reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT); - Piano.revokeDelete(); resolve(); }, ); req.on('error', (err) => { - Piano.revokeDelete(); reject(err); }); @@ -624,8 +632,6 @@ describe('yasumi', () => { it('throws on item not found', () => { return new Promise((resolve, reject) => { - Piano.allowDelete(); - const req = request( { host: HOST, @@ -639,18 +645,15 @@ describe('yasumi', () => { }, (res) => { res.on('error', (err) => { - Piano.revokeDelete(); reject(err); }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); - Piano.revokeDelete(); resolve(); }, ); req.on('error', (err) => { - Piano.revokeDelete(); reject(err); });