From 3942b1efca73dccda655e3e83ce6f61d2efbf3f9 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 18 Mar 2024 12:46:27 +0800 Subject: [PATCH] Add type safety, provide granular error handling Provide utility types for resource schema validation. Also handle each step for POST and PUT handlers. --- examples/basic/server.ts | 10 +- src/core.ts | 94 ++++++----- src/data-sources/file-jsonl.ts | 6 +- src/handlers.ts | 289 +++++++++++++++++++++------------ src/index.ts | 2 +- src/languages/en/index.ts | 6 + src/validation.ts | 5 + test/e2e/default.test.ts | 15 +- 8 files changed, 269 insertions(+), 158 deletions(-) diff --git a/examples/basic/server.ts b/examples/basic/server.ts index 537ba93..4e4cad6 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -1,7 +1,7 @@ import { application, resource, - valibot as v, + validation as v, serializers, encodings, } from '../../src'; @@ -38,14 +38,14 @@ const User = resource(v.object( }, v.never() )) - .name('User') - .fullText('bio') - .id('id', { + .id('id' as const, { generationStrategy: autoIncrement, serialize: (id) => id?.toString() ?? '0', deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, schema: v.number(), - }); + }) + .name('User') + .fullText('bio'); const app = application({ name: 'piano-service', diff --git a/src/core.ts b/src/core.ts index 70d784d..f86286d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -2,7 +2,7 @@ import * as http from 'http'; import * as https from 'https'; import { constants } from 'http2'; import { pluralize } from 'inflection'; -import {BaseSchema, ObjectSchema, Output} from 'valibot'; +import * as v from 'valibot'; import { SerializerPair } from './serializers'; import { handleCreateItem, handleDeleteItem, handleEmplaceItem, @@ -20,11 +20,12 @@ import * as applicationJson from './serializers/application/json'; // TODO separate frontend and backend factory methods -export interface DataSource { +export interface DataSource { initialize(): Promise; - getTotalCount?(): Promise; - getMultiple(): Promise; - getSingle(id: string): Promise; + getTotalCount?(query?: Q): Promise; + getMultiple(query?: Q): Promise; + getById(id: string): Promise; + getSingle?(query?: Q): Promise; create(data: T): Promise; delete(id: string): Promise; emplace(id: string, data: T): Promise<[T, boolean]>; @@ -36,12 +37,12 @@ export interface ApplicationParams { dataSource?: (resource: Resource) => DataSource; } -interface ResourceState { - idAttr: string; +interface ResourceState { + idAttr: IdAttr; itemName: string; collectionName: string; routeName: string; - idConfig: ResourceIdConfig; + idConfig: ResourceIdConfig; fullTextAttrs: Set; canCreate: boolean; canFetchCollection: boolean; @@ -51,11 +52,18 @@ interface ResourceState { canDelete: boolean; } -export interface Resource { +export interface Resource< + ResourceSchema extends v.BaseSchema = v.BaseSchema, + IdAttr extends string = string, + IdSchema extends v.BaseSchema = v.BaseSchema +> { newId(dataSource: DataSource): string | number | unknown; - schema: T; - state: ResourceState; - id(newIdAttr: string, params: ResourceIdConfig): this; + schema: ResourceSchema; + state: ResourceState; + id( + newIdAttr: NewIdAttr, + params: ResourceIdConfig + ): Resource; fullText(fullTextAttr: string): this; name(n: string): this; collection(n: string): this; @@ -68,7 +76,7 @@ export interface Resource extends Resource { +export interface ResourceWithDataSource extends Resource { dataSource: DataSource; } @@ -76,14 +84,14 @@ interface GenerationStrategy { (dataSource: DataSource, ...args: unknown[]): Promise; } -interface ResourceIdConfig { +interface ResourceIdConfig { generationStrategy: GenerationStrategy; serialize: (id: unknown) => string; - deserialize: (id: string) => Output; - schema: T; + deserialize: (id: string) => v.Output; + schema: IdSchema; } -const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { +const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { const middlewares = [] as [string, Middleware][]; if (mainResourceId === '') { if (resource.state.canFetchCollection) { @@ -111,7 +119,11 @@ const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { return middlewares; }; -export const resource = (schema: T): Resource => { +export const resource = < + ResourceSchema extends v.BaseSchema, + IdAttr extends string = string, + IdSchema extends v.BaseSchema = v.BaseSchema +>(schema: ResourceSchema): Resource => { const resourceState = { fullTextAttrs: new Set(), canCreate: false, @@ -120,13 +132,13 @@ export const resource = (sche canPatch: false, canEmplace: false, canDelete: false, - } as Partial>; + } as ResourceState; return { - get state(): ResourceState { + get state(): ResourceState { return Object.freeze({ ...resourceState - }) as unknown as ResourceState; + }) as unknown as ResourceState; }, canFetchCollection(b = true) { resourceState.canFetchCollection = b; @@ -152,10 +164,10 @@ export const resource = (sche resourceState.canDelete = b; return this; }, - id(newIdAttr: string, params: ResourceIdConfig) { + id(newIdAttr: NewIdAttr, params: ResourceIdConfig) { resourceState.idAttr = newIdAttr; resourceState.idConfig = params; - return this; + return this as Resource; }, newId(dataSource: DataSource) { return resourceState?.idConfig?.generationStrategy?.(dataSource); @@ -164,8 +176,8 @@ export const resource = (sche if ( schema.type === 'object' && ( - schema as unknown as ObjectSchema< - Record, + schema as unknown as v.ObjectSchema< + Record, undefined, Record > @@ -207,8 +219,8 @@ export const resource = (sche }, get schema() { return schema; - } - } as Resource; + }, + } as Resource; }; interface CreateServerParams { @@ -232,7 +244,7 @@ interface HandlerState { } export interface ApplicationState { - resources: Set; + resources: Set>; languages: Map; serializers: Map; encodings: Map; @@ -256,6 +268,8 @@ export interface MessageCollection { resourceFetched(resource: Resource): string; resourceNotFound(resource: Resource): string; deleteNonExistingResource(resource: Resource): string; + unableToGenerateIdFromResourceDataSource(resource: Resource): string; + unableToEmplaceResource(resource: Resource): string; unableToSerializeResponse(): string; unableToEncodeResponse(): string; unableToDeleteResource(resource: Resource): string; @@ -293,7 +307,7 @@ export interface BackendState { showTotalItemCountOnCreateItem: boolean; } -interface MiddlewareArgs { +interface MiddlewareArgs { handlerState: HandlerState; backendState: BackendState; appState: ApplicationState; @@ -305,13 +319,13 @@ interface MiddlewareArgs { errorResponseBodyLanguage: [string, MessageCollection]; errorResponseBodyEncoding: [string, EncodingPair]; errorResponseBodyMediaType: [string, SerializerPair]; - resource: ResourceWithDataSource; + resource: ResourceWithDataSource; resourceId: string; query: URLSearchParams; } export interface Middleware { - (args: MiddlewareArgs): RequestListenerWithReturn> + (args: MiddlewareArgs): RequestListenerWithReturn> } export interface Backend { @@ -332,14 +346,14 @@ 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; + resource(resRaw: Partial>): this; createBackend(): Backend; createClient(): Client; } export const application = (appParams: ApplicationParams): Application => { const appState: ApplicationState = { - resources: new Set(), + resources: new Set>(), languages: new Map(), serializers: new Map(), encodings: new Map(), @@ -362,13 +376,13 @@ export const application = (appParams: ApplicationParams): Application => { appState.languages.set(languageCode, messageCollection); return this; }, - resource(resRaw: Partial) { - const res = resRaw as Partial; + 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.state!.itemName} must have a data source.`); } - appState.resources.add(res as ResourceWithDataSource); + appState.resources.add(res as ResourceWithDataSource); return this; }, createClient(): Client { @@ -411,7 +425,6 @@ export const application = (appParams: ApplicationParams): Application => { showTotalItemCountOnCreateItem: false, throws404OnDeletingNotFound: false, checksSerializersOnDelete: false, - }; return { @@ -466,6 +479,7 @@ export const application = (appParams: ApplicationParams): Application => { const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding; const errorEncoding = appState.encodings.get(errorEncodingKey) ?? fallbackEncoding; + // TODO refactor const [currentLanguageCode, currentLanguageMessages] = availableLanguages.find(([code]) => code === languageCandidate) ?? []; if (typeof currentLanguageCode === 'undefined' || typeof currentLanguageMessages === 'undefined') { const data = errorMessageCollection.bodies.languageNotAcceptable(); @@ -513,7 +527,7 @@ export const application = (appParams: ApplicationParams): Application => { return; } - const middlewareArgs: Omit = { + const middlewareArgs: Omit, 'resource' | 'resourceId'> = { handlerState: { handled: false }, @@ -530,13 +544,13 @@ export const application = (appParams: ApplicationParams): Application => { errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], }; - const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); + const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); if (methodAndUrl.handled) { return; } if (url === '/') { - const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); + const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); if (middlewareState.handled) { return; } diff --git a/src/data-sources/file-jsonl.ts b/src/data-sources/file-jsonl.ts index 26dd119..e731979 100644 --- a/src/data-sources/file-jsonl.ts +++ b/src/data-sources/file-jsonl.ts @@ -29,7 +29,7 @@ export class DataSource> implements DataSourceI return [...this.data]; } - async getSingle(idSerialized: string) { + async getById(idSerialized: string) { const id = this.resource.state.idConfig.deserialize(idSerialized); const foundData = this.data.find((s) => s[this.resource.state.idAttr as string] === id); @@ -73,7 +73,7 @@ export class DataSource> implements DataSourceI } async emplace(idSerialized: string, dataWithId: T) { - const existing = await this.getSingle(idSerialized); + const existing = await this.getById(idSerialized); const id = this.resource.state.idConfig.deserialize(idSerialized); const { [this.resource.state.idAttr]: idFromResource, ...data } = dataWithId; const dataToEmplace = { @@ -100,7 +100,7 @@ export class DataSource> implements DataSourceI } async patch(idSerialized: string, data: Partial) { - const existing = await this.getSingle(idSerialized); + const existing = await this.getById(idSerialized); if (!existing) { return null; } diff --git a/src/handlers.ts b/src/handlers.ts index 6453ce4..075033e 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -43,13 +43,13 @@ export const handleGetRoot: Middleware = ({ responseBodyEncoding: [encodingKey, encoding], errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], }) => (_req: IncomingMessage, res: ServerResponse) => { - const singleResDatum = { + const data = { name: appParams.name }; let serialized; try { - serialized = responseBodySerializerPair.serialize(singleResDatum); + serialized = responseBodySerializerPair.serialize(data); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -61,9 +61,9 @@ export const handleGetRoot: Middleware = ({ }; } - let theFormatted; + let encoded; try { - theFormatted = encoding.encode(serialized); + encoded = encoding.encode(serialized); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -99,7 +99,7 @@ export const handleGetRoot: Middleware = ({ } res.writeHead(constants.HTTP_STATUS_OK, theHeaders); res.statusMessage = responseBodyMessageCollection.statusMessages.ok(); - res.end(theFormatted); + res.end(encoded); return { handled: true }; @@ -112,6 +112,7 @@ export const handleGetCollection: Middleware = ({ responseBodyEncoding: [encodingKey, encoding], errorResponseBodyLanguage: [errorLanguageCode, errorMessageCollection], backendState, + query, }) => async (_req: IncomingMessage, res: ServerResponse) => { try { await resource.dataSource.initialize(); @@ -126,13 +127,13 @@ export const handleGetCollection: Middleware = ({ }; } - let resData: Object[]; + let data: v.Output[]; let totalItemCount: number | undefined; try { // TODO querying mechanism - resData = await resource.dataSource.getMultiple(); // TODO paginated responses per resource + data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { - totalItemCount = await resource.dataSource.getTotalCount(); + totalItemCount = await resource.dataSource.getTotalCount(query); } } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { @@ -147,7 +148,7 @@ export const handleGetCollection: Middleware = ({ let serialized; try { - serialized = responseBodySerializerPair.serialize(resData); + serialized = responseBodySerializerPair.serialize(data); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -159,9 +160,9 @@ export const handleGetCollection: Middleware = ({ }; } - let theFormatted; + let encoded; try { - theFormatted = encoding.encode(serialized); + encoded = encoding.encode(serialized); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -185,7 +186,7 @@ export const handleGetCollection: Middleware = ({ res.writeHead(constants.HTTP_STATUS_OK, headers); res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCollectionFetched(resource); - res.end(theFormatted); + res.end(encoded); return { handled: true }; @@ -212,9 +213,9 @@ export const handleGetItem: Middleware = ({ }; } - let singleResDatum: Object | null = null; + let data: v.Output | null = null; try { - singleResDatum = await resource.dataSource.getSingle(resourceId); + data = await resource.dataSource.getById(resourceId); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -228,7 +229,7 @@ export const handleGetItem: Middleware = ({ let serialized: string | null; try { - serialized = singleResDatum === null ? null : responseBodySerializerPair.serialize(singleResDatum); + serialized = data === null ? null : responseBodySerializerPair.serialize(data); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -240,9 +241,9 @@ export const handleGetItem: Middleware = ({ }; } - let theFormatted; + let encoded; try { - theFormatted = serialized === null ? null : encoding.encode(serialized); + encoded = serialized === null ? null : encoding.encode(serialized); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -254,14 +255,14 @@ export const handleGetItem: Middleware = ({ }; } - if (theFormatted) { + if (encoded) { res.writeHead(constants.HTTP_STATUS_OK, { 'Content-Type': responseBodyMediaType, 'Content-Language': languageCode, 'Content-Encoding': encodingKey, }); res.statusMessage = responseBodyMessageCollection.statusMessages.resourceFetched(resource) - res.end(theFormatted); + res.end(encoded); return { handled: true }; @@ -298,50 +299,50 @@ export const handleDeleteItem: Middleware = ({ }; } - let response; + let existing: unknown | null; try { - response = await resource.dataSource.delete(resourceId); + existing = await resource.dataSource.getById(resourceId); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, }); - res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource); + res.statusMessage = errorMessageCollection.statusMessages.unableToFetchResource(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 (!existing && backendState.throws404OnDeletingNotFound) { + res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.deleteNonExistingResource(resource); + res.end(); + return { + handled: true + }; + } - if (throwOnNotFound) { - // TODO provide error message + try { + if (existing) { + await resource.dataSource.delete(resourceId); + } + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToDeleteResource(resource); res.end(); return { handled: true }; } + res.writeHead(constants.HTTP_STATUS_NO_CONTENT, { + 'Content-Language': languageCode, + }); + res.statusMessage = responseBodyMessageCollection.statusMessages.resourceDeleted(resource); res.end(); return { handled: true @@ -384,9 +385,9 @@ export const handlePatchItem: Middleware = ({ }; } - let existing: object | null; + let existing: unknown | null; try { - existing = await resource.dataSource.getSingle(resourceId); + existing = await resource.dataSource.getById(resourceId); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -411,7 +412,7 @@ export const handlePatchItem: Middleware = ({ let bodyDeserialized: unknown; try { - const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema : resource.schema + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema bodyDeserialized = await getBody( req, requestBodyDeserializerPair, @@ -444,22 +445,21 @@ export const handlePatchItem: Middleware = ({ `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` )), ); - const theFormatted = errorEncoding.encode(serialized); + const encoded = 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); + res.end(encoded); return { handled: true, }; } const params = bodyDeserialized as Record; - - let newObject: object | null; + let newObject: v.Output | null; try { newObject = await resource.dataSource.patch(resourceId, params); } catch { @@ -487,9 +487,9 @@ export const handlePatchItem: Middleware = ({ }; } - let theFormatted; + let encoded; try { - theFormatted = encoding.encode(serialized); + encoded = encoding.encode(serialized); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, @@ -507,7 +507,7 @@ export const handlePatchItem: Middleware = ({ 'Content-Encoding': encodingKey, }); res.statusMessage = responseBodyMessageCollection.statusMessages.resourcePatched(resource); - res.end(theFormatted); + res.end(encoded); return { handled: true }; @@ -567,14 +567,14 @@ export const handleCreateItem: Middleware = ({ `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` )), ); - const theFormatted = errorEncoding.encode(serialized); + const encoded = 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); + res.end(encoded); return { handled: true, }; @@ -593,37 +593,79 @@ export const handleCreateItem: Middleware = ({ }; } + //v.Output + + let newId; + let params: v.Output; try { - // TODO error handling for each process - const newId = await resource.newId(resource.dataSource); - const params = bodyDeserialized as Record; + newId = await resource.newId(resource.dataSource); + params = bodyDeserialized as Record; params[resource.state.idAttr] = newId; - const newObject = await resource.dataSource.create(params); - let totalItemCount: number | undefined; + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToGenerateIdFromResourceDataSource(resource); + res.end(); + return { + handled: true, + }; + // noop + // TODO + } + + let newObject; + let totalItemCount: number | undefined; + try { + newObject = await resource.dataSource.create(params); 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}` + } catch { + // noop + // TODO + } + + let serialized; + try { + serialized = responseBodySerializerPair.serialize(newObject); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); + res.end(); + return { + handled: true, }; - 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); + } + + let encoded; + try { + encoded = encoding.encode(serialized); } catch { res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { 'Content-Language': errorLanguageCode, - }) - res.statusMessage = `Could Not Return ${resource.state.itemName}`; + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToEncodeResponse(); res.end(); + return { + handled: true, + }; } + + 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(); + } + res.writeHead(constants.HTTP_STATUS_CREATED, headers); + res.statusMessage = responseBodyMessageCollection.statusMessages.resourceCreated(resource); + res.end(encoded); return { handled: true }; @@ -656,7 +698,7 @@ export const handleEmplaceItem: Middleware = ({ let bodyDeserialized: unknown; try { - const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema : resource.schema + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema bodyDeserialized = await getBody( req, requestBodyDeserializerPair, @@ -694,14 +736,14 @@ export const handleEmplaceItem: Middleware = ({ `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` )), ); - const theFormatted = errorEncoding.encode(serialized); + const encoded = 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); + res.end(encoded); return { handled: true, }; @@ -720,40 +762,73 @@ export const handleEmplaceItem: Middleware = ({ }; } + let newObject: v.Output; + let isCreated: boolean; try { - // TODO error handling for each process const params = bodyDeserialized as Record; params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); - const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); - const serialized = responseBodySerializerPair.serialize(newObject); - const theFormatted = encoding.encode(serialized); - const headers: Record = { - 'Content-Type': responseBodyMediaType, - 'Content-Language': languageCode, - 'Content-Encoding': encodingKey, + [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); + } catch { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToEmplaceResource(resource); + res.end(); + return { + handled: true }; - let totalItemCount: number | undefined; - if (backendState.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { - totalItemCount = await resource.dataSource.getTotalCount(); - } - if (isCreated) { - 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); + } + + let serialized; + try { + serialized = responseBodySerializerPair.serialize(newObject); } catch { - res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${resource.state.itemName}`; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { + 'Content-Language': errorLanguageCode, + }); + res.statusMessage = errorMessageCollection.statusMessages.unableToSerializeResponse(); res.end(); + return { + handled: true, + }; } + + let encoded; + try { + encoded = 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 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) { + 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(encoded); return { handled: true }; diff --git a/src/index.ts b/src/index.ts index 3de64b8..7189d7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './core'; -export * as valibot from './validation'; +export * as validation from './validation'; export * as dataSources from './data-sources'; export * as serializers from './serializers'; export * as encodings from './encodings'; diff --git a/src/languages/en/index.ts b/src/languages/en/index.ts index a315097..eb69b2f 100644 --- a/src/languages/en/index.ts +++ b/src/languages/en/index.ts @@ -79,6 +79,12 @@ export const messages: MessageCollection = { }, resourceReplaced(resource: Resource): string { return `${resource.state.itemName} Replaced`; + }, + unableToGenerateIdFromResourceDataSource(resource: Resource): string { + return `Unable To Generate ID From ${resource.state.itemName} Data Source`; + }, + unableToEmplaceResource(resource: Resource): string { + return `Unable To Emplace ${resource.state.itemName}`; } }, bodies: { diff --git a/src/validation.ts b/src/validation.ts index 1120a62..a4d50fa 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,5 +1,6 @@ import * as v from 'valibot'; export * from 'valibot'; +import { Resource } from './core'; export const datelike = () => v.transform( v.union([ @@ -11,3 +12,7 @@ export const datelike = () => v.transform( (value) => new Date(value).toISOString(), v.string([v.isoTimestamp()]) ); + +export type ResourceType = v.Output; + +export type ResourceTypeWithId = ResourceType & Record>; diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts index f25c4b0..b3eccfa 100644 --- a/test/e2e/default.test.ts +++ b/test/e2e/default.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, + test, } from 'vitest'; import { tmpdir @@ -26,7 +27,7 @@ import { Resource, resource, serializers, - valibot as v, + validation as v, } from '../../src'; import {request, Server} from 'http'; import {constants} from 'http2'; @@ -79,7 +80,7 @@ describe('yasumi', () => { v.never() )) .name('Piano') - .id('id', { + .id('id' as const, { generationStrategy: autoIncrement, serialize: (id) => id?.toString() ?? '0', deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, @@ -204,6 +205,7 @@ describe('yasumi', () => { it('returns data', () => { return new Promise((resolve, reject) => { + // TODO all responses should have serialized ids const req = request( { host: HOST, @@ -660,4 +662,13 @@ describe('yasumi', () => { }); }); }); + + // https://github.com/mayajs/maya/blob/main/test/index.test.ts + // + // peak unit test + describe("Contribute to see a unit test", () => { + test("should have a unit test", () => { + expect("Is this a unit test?").not.toEqual("Yes this is a unit test."); + }); + }); });