From d94f7e4c44256faf4e2e48f150fc0dc9ab5f4c87 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 21 Mar 2024 15:48:07 +0800 Subject: [PATCH] Major refactor of codebase Organize code even further to improve extensibility. --- README.md | 8 +- examples/basic/server.ts | 42 +- src/app.ts | 61 ++ src/backend/common.ts | 21 + src/backend/core.ts | 162 ++++ src/backend/data-source.ts | 13 + src/{ => backend}/data-sources/file-jsonl.ts | 5 +- src/{ => backend}/data-sources/index.ts | 0 src/backend/extenders/method.ts | 20 + src/backend/extenders/url.ts | 27 + src/backend/handlers.ts | 290 ++++++ src/backend/index.ts | 2 + src/backend/server.ts | 533 +++++++++++ src/backend/utils.ts | 28 + src/client/index.ts | 48 + src/common/app.ts | 16 + src/{encodings/index.ts => common/charset.ts} | 4 +- src/common/charsets/index.ts | 1 + src/{encodings => common/charsets}/utf-8.ts | 0 src/common/data-source.ts | 5 + src/common/index.ts | 5 + src/{common.ts => common/language.ts} | 16 +- src/{ => common}/languages/en/index.ts | 10 +- src/common/languages/index.ts | 1 + src/common/media-type.ts | 5 + .../media-types}/application/json.ts | 0 src/common/media-types/index.ts | 2 + src/common/resource.ts | 149 ++++ src/{ => common}/validation.ts | 2 +- src/core.ts | 585 ------------ src/handlers.ts | 835 ------------------ src/index.ts | 15 +- src/serializers/index.ts | 8 - src/utils.ts | 54 -- test/e2e/default.test.ts | 90 +- tsconfig.json | 5 +- 36 files changed, 1492 insertions(+), 1576 deletions(-) create mode 100644 src/app.ts create mode 100644 src/backend/common.ts create mode 100644 src/backend/core.ts create mode 100644 src/backend/data-source.ts rename src/{ => backend}/data-sources/file-jsonl.ts (94%) rename src/{ => backend}/data-sources/index.ts (100%) create mode 100644 src/backend/extenders/method.ts create mode 100644 src/backend/extenders/url.ts create mode 100644 src/backend/handlers.ts create mode 100644 src/backend/index.ts create mode 100644 src/backend/server.ts create mode 100644 src/backend/utils.ts create mode 100644 src/client/index.ts create mode 100644 src/common/app.ts rename src/{encodings/index.ts => common/charset.ts} (56%) create mode 100644 src/common/charsets/index.ts rename src/{encodings => common/charsets}/utf-8.ts (100%) create mode 100644 src/common/data-source.ts create mode 100644 src/common/index.ts rename src/{common.ts => common/language.ts} (82%) rename src/{ => common}/languages/en/index.ts (90%) create mode 100644 src/common/languages/index.ts create mode 100644 src/common/media-type.ts rename src/{serializers => common/media-types}/application/json.ts (100%) create mode 100644 src/common/media-types/index.ts create mode 100644 src/common/resource.ts rename src/{ => common}/validation.ts (92%) delete mode 100644 src/core.ts delete mode 100644 src/handlers.ts delete mode 100644 src/serializers/index.ts delete mode 100644 src/utils.ts diff --git a/README.md b/README.md index 39bf94e..5e682e6 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ See [docs folder](./docs) for more details. https://www.rfc-editor.org/rfc/rfc9457.html -- RFC 5988 - Web Linking +- ~~RFC 5988 - Web Linking~~ - https://datatracker.ietf.org/doc/html/rfc5988 + ~~https://datatracker.ietf.org/doc/html/rfc5988~~ + +- RFC 9288 - Web Linking + + https://httpwg.org/specs/rfc8288.html diff --git a/examples/basic/server.ts b/examples/basic/server.ts index a1826aa..16f8964 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -2,8 +2,8 @@ import { application, resource, validation as v, - serializers, - encodings, + mediaTypes, + charsets, } from '../../src'; import {TEXT_SERIALIZER_PAIR} from './serializers'; import {autoIncrement, dataSource} from './data-source'; @@ -49,28 +49,30 @@ const User = resource(v.object( const app = application({ name: 'piano-service', - dataSource, }) - .contentType(serializers.applicationJson) - .contentType(serializers.textJson) - .contentType(TEXT_SERIALIZER_PAIR) - .encoding(encodings.utf8) + .mediaType(mediaTypes.applicationJson) + .mediaType(mediaTypes.textJson) + .mediaType(TEXT_SERIALIZER_PAIR) + .charset(charsets.utf8) .resource(Piano) .resource(User); -const backend = app.createBackend(); +app.create({ + dataSource, +}).then((backend) => { + const server = backend.createServer({ + basePath: '/api' + }); + + server.listen(3000); -const server = backend.createServer({ - baseUrl: '/api' + setTimeout(() => { + // Allow user operations after 5 seconds from startup + User + .canFetchItem() + .canFetchCollection() + .canCreate() + .canPatch(); + }, 5000); }); -server.listen(3000); - -setTimeout(() => { - // Allow user operations after 5 seconds from startup - User - .canFetchItem() - .canFetchCollection() - .canCreate() - .canPatch(); -}, 5000); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..5359b99 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,61 @@ +import * as v from 'valibot'; +import * as en from './common/languages/en'; +import * as utf8 from './common/charsets/utf-8'; +import * as applicationJson from './common/media-types/application/json'; +import {Resource, Language, MediaType, Charset, ApplicationParams, ApplicationState} from './common'; +import {BackendBuilder, createBackend, CreateBackendParams} from './backend'; +import {ClientBuilder, createClient, CreateClientParams} from './client'; + +export interface ApplicationBuilder { + mediaType(mediaType: MediaType): this; + language(language: Language): this; + charset(charset: Charset): this; + resource(resRaw: Resource): this; + createBackend(params: Omit): BackendBuilder; + createClient(params: Omit): ClientBuilder; +} + +export const application = (appParams: ApplicationParams): ApplicationBuilder => { + const appState: ApplicationState = { + name: appParams.name, + resources: new Set>(), + languages: new Set(), + mediaTypes: new Set(), + charsets: new Set(), + }; + + appState.languages.add(en); + appState.charsets.add(utf8); + appState.mediaTypes.add(applicationJson); + + return { + mediaType(serializerPair: MediaType) { + appState.mediaTypes.add(serializerPair); + return this; + }, + charset(encodingPair: Charset) { + appState.charsets.add(encodingPair); + return this; + }, + language(language: Language) { + appState.languages.add(language); + return this; + }, + resource(resRaw: Resource) { + appState.resources.add(resRaw); + return this; + }, + createBackend(params: Omit) { + return createBackend({ + ...params, + app: appState + }); + }, + createClient(params: Omit) { + return createClient({ + ...params, + app: appState + }); + }, + }; +}; diff --git a/src/backend/common.ts b/src/backend/common.ts new file mode 100644 index 0000000..448be36 --- /dev/null +++ b/src/backend/common.ts @@ -0,0 +1,21 @@ +import {ApplicationState, Charset, Language, MediaType, Resource} from '../common'; +import {BaseDataSource} from '../common/data-source'; + +export interface BackendState { + app: ApplicationState; + dataSource: (resource: Resource) => BaseDataSource; + cn: { + language: Language; + charset: Charset; + mediaType: MediaType; + } + errorHeaders: { + language?: string; + charset?: string; + serializer?: string; + } + showTotalItemCountOnGetCollection: boolean; + throws404OnDeletingNotFound: boolean; + checksSerializersOnDelete: boolean; + showTotalItemCountOnCreateItem: boolean; +} diff --git a/src/backend/core.ts b/src/backend/core.ts new file mode 100644 index 0000000..30bfec0 --- /dev/null +++ b/src/backend/core.ts @@ -0,0 +1,162 @@ +import * as v from 'valibot'; +import {ApplicationState, Language, LanguageStatusMessageMap, Resource} from '../common'; +import http from 'http'; +import {createServer, CreateServerParams} from './server'; +import https from 'https'; +import {BackendState} from './common'; +import {BaseDataSource} from '../common/data-source'; +import * as en from '../common/languages/en'; +import * as utf8 from '../common/charsets/utf-8'; +import * as applicationJson from '../common/media-types/application/json'; +import {DataSource} from './data-source'; + +export interface BackendResource< + DataSourceType extends BaseDataSource = DataSource, + ResourceSchema extends v.BaseSchema = v.BaseSchema, + ResourceName extends string = string, + ResourceRouteName extends string = string, + IdAttr extends string = string, + IdSchema extends v.BaseSchema = v.BaseSchema +> extends Resource { + newId(dataSource: DataSourceType): string | number | unknown; + dataSource: DataSourceType; +} + +export interface RequestContext extends http.IncomingMessage {} + +export interface BackendBuilder { + showTotalItemCountOnGetCollection(b?: boolean): this; + showTotalItemCountOnCreateItem(b?: boolean): this; + checksSerializersOnDelete(b?: boolean): this; + throws404OnDeletingNotFound(b?: boolean): this; + createServer(serverParams?: CreateServerParams): http.Server | https.Server; + dataSource?: (resource: Resource) => T; +} + +export class MiddlewareError extends Error {} + +interface ResponseParams { + statusCode: Response['statusCode']; + statusMessage?: Response['statusMessage']; + headers?: Response['headers']; +} + + +interface PlainResponseParams extends ResponseParams { + body?: T; +} + +interface StreamResponseParams extends ResponseParams { + stream: NodeJS.ReadableStream; +} + + +interface HttpMiddlewareErrorParams extends Omit, 'statusMessage'> { + cause?: unknown +} + +export interface Response { + statusCode: number; + + statusMessage?: keyof LanguageStatusMessageMap; + + headers?: Record; +} + +export class PlainResponse implements Response { + readonly statusCode: Response['statusCode']; + + readonly statusMessage?: keyof LanguageStatusMessageMap; + + readonly headers: Response['headers']; + + readonly body?: T; + + constructor(args: PlainResponseParams) { + this.statusCode = args.statusCode; + this.statusMessage = args.statusMessage; + this.headers = args.headers; + this.body = args.body; + } +} + +export class StreamResponse implements Response { + readonly statusCode: Response['statusCode']; + + readonly statusMessage?: keyof LanguageStatusMessageMap; + + readonly headers: Response['headers']; + + readonly stream: NodeJS.ReadableStream; + + constructor(args: StreamResponseParams) { + this.statusCode = args.statusCode; + this.statusMessage = args.statusMessage; + this.headers = args.headers; + this.stream = args.stream; + } +} + +export class HttpMiddlewareError extends MiddlewareError { + readonly response: PlainResponse; + + constructor(statusMessage: keyof Language['statusMessages'], params: HttpMiddlewareErrorParams) { + super(statusMessage, { cause: params.cause }); + this.response = new PlainResponse({ + ...params, + statusMessage, + }); + } +} + +export interface ResponseContext extends http.ServerResponse {} + +export interface CreateBackendParams { + app: ApplicationState; + dataSource: (resource: Resource) => BaseDataSource; +} + +export const createBackend = (params: CreateBackendParams) => { + const backendState: BackendState = { + app: params.app, + dataSource: params.dataSource, + cn: { + language: en, + charset: utf8, + mediaType: applicationJson + }, + errorHeaders: { + // undefined follows user accept headers strictly + // + language: undefined, + charset: 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) { + return createServer(backendState, serverParams); + } + } satisfies BackendBuilder; +}; diff --git a/src/backend/data-source.ts b/src/backend/data-source.ts new file mode 100644 index 0000000..d85f2ec --- /dev/null +++ b/src/backend/data-source.ts @@ -0,0 +1,13 @@ +import {BaseDataSource} from '../common/data-source'; + +export interface DataSource extends BaseDataSource { + initialize(): 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]>; + patch(id: string, data: Partial): Promise; +} diff --git a/src/data-sources/file-jsonl.ts b/src/backend/data-sources/file-jsonl.ts similarity index 94% rename from src/data-sources/file-jsonl.ts rename to src/backend/data-sources/file-jsonl.ts index e731979..edc6a52 100644 --- a/src/data-sources/file-jsonl.ts +++ b/src/backend/data-sources/file-jsonl.ts @@ -1,6 +1,7 @@ import {readFile, writeFile} from 'fs/promises'; import {join} from 'path'; -import {DataSource as DataSourceInterface, Resource} from '../core'; +import {DataSource as DataSourceInterface} from '../data-source'; +import {Resource} from '../..'; export class DataSource> implements DataSourceInterface { private readonly path: string; @@ -8,7 +9,7 @@ export class DataSource> implements DataSourceI data: T[] = []; constructor(private readonly resource: Resource, baseDir = '') { - this.path = join(baseDir, `${this.resource.state.collectionName}.jsonl`); + this.path = join(baseDir, `${this.resource.state.routeName}.jsonl`); } async initialize() { diff --git a/src/data-sources/index.ts b/src/backend/data-sources/index.ts similarity index 100% rename from src/data-sources/index.ts rename to src/backend/data-sources/index.ts diff --git a/src/backend/extenders/method.ts b/src/backend/extenders/method.ts new file mode 100644 index 0000000..0eb6d61 --- /dev/null +++ b/src/backend/extenders/method.ts @@ -0,0 +1,20 @@ +import {constants} from 'http2'; +import http from 'http'; +import {HttpMiddlewareError} from '../index'; + +interface RequestContext extends http.IncomingMessage { + method: string; +} + +export const adjustMethod = (req: RequestContext) => { + if (!req.method) { + throw new HttpMiddlewareError('methodNotAllowed', { + statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED, + headers: { + 'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', + }, + }); + } + + req.method = req.method.trim().toUpperCase(); +}; diff --git a/src/backend/extenders/url.ts b/src/backend/extenders/url.ts new file mode 100644 index 0000000..d964f91 --- /dev/null +++ b/src/backend/extenders/url.ts @@ -0,0 +1,27 @@ +import {constants} from 'http2'; +import http from 'http'; +import {HttpMiddlewareError} from '..'; + +interface RequestContext extends http.IncomingMessage { + basePath?: string; + + query?: URLSearchParams; + + rawUrl: string; +} + +export const adjustUrl = (req: RequestContext) => { + if (!req.url) { + throw new HttpMiddlewareError('badRequest', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + }); + } + + const theBasePathUrl = req.basePath ?? ''; + const basePath = new URL(theBasePathUrl, 'http://localhost'); + const parsedUrl = new URL(`${theBasePathUrl}/${req.url}`, 'http://localhost'); + req.rawUrl = req.url; + req.url = req.url.slice(basePath.pathname.length); + req.query = parsedUrl.searchParams; + return; +}; diff --git a/src/backend/handlers.ts b/src/backend/handlers.ts new file mode 100644 index 0000000..f3a745d --- /dev/null +++ b/src/backend/handlers.ts @@ -0,0 +1,290 @@ +import { constants } from 'http2'; +import * as v from 'valibot'; +import {HttpMiddlewareError, PlainResponse} from './core'; +import {Middleware} from './server'; + +export const handleGetRoot: Middleware = (req) => { + const { backend, basePath } = req; + + const data = { + name: backend.app.name + }; + + const registeredResources = Array.from(backend.app.resources); + const availableResources = registeredResources.filter((r) => ( + r.state.canFetchCollection + || r.state.canCreate + )); + + const headers: Record = {}; + if (availableResources.length > 0) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + headers['Link'] = availableResources + .map((r) => [ + `<${basePath}/${r.state.routeName}>`, + 'rel="related"', + `name="${encodeURIComponent(r.state.routeName)}"` + ].join('; ')) + .join(', '); + } + + return new PlainResponse({ + headers, + statusMessage: 'ok', + statusCode: constants.HTTP_STATUS_OK, + body: data + }); +}; + +export const handleGetCollection: Middleware = async (req) => { + const { query, resource, backend } = req; + + let data: v.Output[]; + let totalItemCount: number | undefined; + try { + // TODO querying mechanism + data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource + if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { + totalItemCount = await resource.dataSource.getTotalCount(query); + } + } catch (cause) { + throw new HttpMiddlewareError( + 'unableToFetchResourceCollection', + { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } + + const headers: Record = {}; + + if (typeof totalItemCount !== 'undefined') { + headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); + } + + return new PlainResponse({ + headers, + statusCode: constants.HTTP_STATUS_OK, + statusMessage: 'resourceCollectionFetched', + body: data, + }); +}; + +export const handleGetItem: Middleware = async (req) => { + const { resource, resourceId } = req; + + if (typeof resourceId === 'undefined') { + throw new HttpMiddlewareError( + 'resourceIdNotGiven', + { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } + + let data: v.Output | null = null; + try { + data = await resource.dataSource.getById(resourceId); + } catch (cause) { + throw new HttpMiddlewareError( + 'unableToFetchResource', + { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } + + if (typeof data !== 'undefined' && data !== null) { + return new PlainResponse({ + statusCode: constants.HTTP_STATUS_OK, + statusMessage: 'resourceFetched', + body: data + }); + } + + throw new HttpMiddlewareError( + 'resourceNotFound', + { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + } + ); +}; + +export const handleDeleteItem: Middleware = async (req) => { + const { resource, resourceId, backend } = req; + + if (typeof resourceId === 'undefined') { + throw new HttpMiddlewareError( + 'resourceIdNotGiven', + { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } + + let existing: unknown | null; + try { + existing = await resource.dataSource.getById(resourceId); + } catch (cause) { + throw new HttpMiddlewareError('unableToFetchResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR + }); + } + + if (!existing && backend.throws404OnDeletingNotFound) { + throw new HttpMiddlewareError('deleteNonExistingResource', { + statusCode: constants.HTTP_STATUS_NOT_FOUND + }); + } + + try { + if (existing) { + await resource.dataSource.delete(resourceId); + } + } catch (cause) { + throw new HttpMiddlewareError('unableToDeleteResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR + }) + } + + return new PlainResponse({ + statusCode: constants.HTTP_STATUS_NO_CONTENT, + statusMessage: 'resourceDeleted', + }); +}; + +export const handlePatchItem: Middleware = async (req) => { + const { resource, resourceId, body } = req; + + let existing: unknown | null; + try { + existing = await resource.dataSource.getById(resourceId!); + } catch (cause) { + throw new HttpMiddlewareError('unableToFetchResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + }); + } + + if (!existing) { + throw new HttpMiddlewareError('patchNonExistingResource', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + }); + } + + let newObject: v.Output | null; + try { + newObject = await resource.dataSource.patch(resourceId!, body as object); + } catch (cause) { + throw new HttpMiddlewareError('unableToPatchResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR + }); + } + + return new PlainResponse({ + statusCode: constants.HTTP_STATUS_OK, + statusMessage: 'resourcePatched', + body: newObject, + }); + + // TODO finish the rest of the handlers!!! +}; + +export const handleCreateItem: Middleware = async (req) => { + const { resource, body, backend, basePath } = req; + + let newId; + let params: v.Output; + try { + newId = await resource.newId(resource.dataSource); + params = { ...body as Record }; + params[resource.state.idAttr] = newId; + } catch (cause) { + throw new HttpMiddlewareError('unableToGenerateIdFromResourceDataSource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + }); + } + + let newObject; + let totalItemCount: number | undefined; + try { + newObject = await resource.dataSource.create(params); + if (backend.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { + totalItemCount = await resource.dataSource.getTotalCount(); + } + } catch (cause) { + throw new HttpMiddlewareError('unableToCreateResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + }); + } + + const location = `${basePath}/${resource.state.routeName}/${newId}`; + + if (typeof totalItemCount !== 'undefined') { + return new PlainResponse({ + statusCode: constants.HTTP_STATUS_CREATED, + headers: { + 'Location': location, + 'X-Resource-Total-Item-Count': totalItemCount.toString() + }, + body: newObject, + statusMessage: 'resourceCreated' + }); + } + + return new PlainResponse({ + statusCode: constants.HTTP_STATUS_CREATED, + body: newObject, + headers: { + 'Location': location, + }, + statusMessage: 'resourceCreated' + }); +} + +export const handleEmplaceItem: Middleware = async (req) => { + const { resource, resourceId, basePath, body, backend } = req; + + let newObject: v.Output; + let isCreated: boolean; + try { + const params = { ...body as Record }; + params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); + [newObject, isCreated] = await resource.dataSource.emplace(resourceId!, params); + } catch (cause) { + throw new HttpMiddlewareError('unableToEmplaceResource', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + }); + } + + const headers: Record = {}; + let totalItemCount: number | undefined; + if (backend.showTotalItemCountOnCreateItem && typeof resource.dataSource.getTotalCount === 'function') { + totalItemCount = await resource.dataSource.getTotalCount(); + } + if (isCreated) { + headers['Location'] = `${basePath}/${resource.state.routeName}/${resourceId}`; + if (typeof totalItemCount !== 'undefined') { + headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); + } + } + + return new PlainResponse({ + statusCode: isCreated ? constants.HTTP_STATUS_CREATED : constants.HTTP_STATUS_OK, + headers, + statusMessage: ( + isCreated + ? 'resourceCreated' + : 'resourceReplaced' + ), + body: newObject + }); +} diff --git a/src/backend/index.ts b/src/backend/index.ts new file mode 100644 index 0000000..ab74d0d --- /dev/null +++ b/src/backend/index.ts @@ -0,0 +1,2 @@ +export * from './core'; +export * as dataSources from './data-sources'; diff --git a/src/backend/server.ts b/src/backend/server.ts new file mode 100644 index 0000000..ce5562a --- /dev/null +++ b/src/backend/server.ts @@ -0,0 +1,533 @@ +import http from 'http'; +import {BackendState} from './common'; +import {Language, Resource, Charset, MediaType} from '../common'; +import * as applicationJson from '../common/media-types/application/json'; +import * as utf8 from '../common/charsets/utf-8'; +import * as en from '../common/languages/en'; +import https from 'https'; +import Negotiator from 'negotiator'; +import {constants} from 'http2'; +import {adjustMethod} from './extenders/method'; +import {adjustUrl} from './extenders/url'; +import { + handleCreateItem, + handleDeleteItem, + handleEmplaceItem, + handleGetCollection, + handleGetItem, + handleGetRoot, + handlePatchItem, +} from './handlers'; +import { + HttpMiddlewareError, + PlainResponse, + ResponseContext, + StreamResponse, + Response, + BackendResource, +} from './core'; +import * as v from 'valibot'; +import {getBody} from './utils'; +import {DataSource} from './data-source'; + +export interface CreateServerParams { + basePath?: string; + host?: string; + cert?: string; + key?: string; + requestTimeout?: number; + // CQRS + streamResponses?: boolean; +} + +export interface RequestContext extends http.IncomingMessage { + backend: BackendState; + + host: string; + + scheme: string; + + basePath: string; + + method: string; + + url: string; + + rawUrl: string; + + cn: { + language: Language; + mediaType: MediaType; + charset: Charset; + }; + + query: URLSearchParams; + + resource: BackendResource; + + resourceId?: string; + + body?: unknown; +} + +export interface Middleware { + (req: Req): undefined | Response | Promise; +} + +const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { + const middlewares = [] as [string, Middleware, v.BaseSchema?][]; + if (mainResourceId === '') { + if (resource.state.canFetchCollection) { + middlewares.push(['GET', handleGetCollection]); + } + if (resource.state.canCreate) { + middlewares.push(['POST', handleCreateItem, resource.schema]); + } + return middlewares; + } + + if (resource.state.canFetchItem) { + middlewares.push(['GET', handleGetItem]); + } + if (resource.state.canEmplace) { + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; + const putSchema = ( + schema.type === 'object' + ? v.merge([ + schema as v.ObjectSchema, + v.object({ + [resource.state.idAttr]: v.transform( + v.any(), + input => resource.state.idConfig.serialize(input), + v.literal(mainResourceId) + ) + }) + ]) + : schema + ); + middlewares.push(['PUT', handleEmplaceItem, putSchema]); + } + if (resource.state.canPatch) { + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; + const patchSchema = ( + schema.type === 'object' + ? v.partial( + schema as v.ObjectSchema, + (schema as v.ObjectSchema).rest, + (schema as v.ObjectSchema).pipe + ) + : schema + ); + middlewares.push(['PATCH', handlePatchItem, patchSchema]); + } + if (resource.state.canDelete) { + middlewares.push(['DELETE', handleDeleteItem]); + } + + return middlewares; +}; + +export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { + const isHttps = 'key' in serverParams && 'cert' in serverParams; + + class ServerYasumiRequest extends http.IncomingMessage implements RequestContext { + readonly host = serverParams.host ?? 'localhost'; + + readonly scheme = isHttps ? 'https' : 'http'; + + readonly basePath = serverParams.basePath ?? ''; + + readonly backend = backendState; + + resource = undefined as unknown as BackendResource; + + resourceId?: string; + + query = new URLSearchParams(); + + body?: unknown; + + method = ''; + + url = ''; + + rawUrl = ''; + + readonly cn: { + language: Language; + mediaType: MediaType; + charset: Charset; + } = { + language: en, + mediaType: applicationJson, + charset: utf8, + }; + } + + class ServerYasumiResponse extends http.ServerResponse { + + } + + const server = isHttps + ? https.createServer({ + key: serverParams.key, + cert: serverParams.cert, + requestTimeout: serverParams.requestTimeout, + IncomingMessage: ServerYasumiRequest, + ServerResponse: ServerYasumiResponse, + }) + : http.createServer({ + requestTimeout: serverParams.requestTimeout, + IncomingMessage: ServerYasumiRequest, + ServerResponse: ServerYasumiResponse, + }); + + const adjustRequestForContentNegotiation = (req: RequestContext, res: ResponseContext) => { + + const negotiator = new Negotiator(req); + const availableLanguages = Array.from(req.backend.app.languages); + const availableCharsets = Array.from(req.backend.app.charsets); + const availableMediaTypes = Array.from(req.backend.app.mediaTypes); + + const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? backendState.cn.language.name; + const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? backendState.cn.charset.name; + const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? backendState.cn.mediaType.name; + + // TODO refactor + const currentLanguage = availableLanguages.find((l) => l.name === languageCandidate); + if (typeof currentLanguage === 'undefined') { + const data = req.backend?.cn.language.bodies.languageNotAcceptable(); + const responseRaw = req.backend?.cn.mediaType.serialize(data); + const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; + res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { + 'Content-Language': req.backend?.cn.language.name, + 'Content-Type': [ + req.backend?.cn.mediaType.name, + `charset="${req.backend?.cn.charset.name}"` + ].join('; '), + }); + res.statusMessage = req.backend?.cn.language.statusMessages.languageNotAcceptable() ?? ''; + res.end(response); + return; + } + + const currentMediaType = availableMediaTypes.find((l) => l.name === mediaTypeCandidate); + if (typeof currentMediaType === 'undefined') { + const data = req.backend?.cn.language.bodies.mediaTypeNotAcceptable(); + const responseRaw = req.backend?.cn.mediaType.serialize(data); + const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; + res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { + 'Content-Language': req.backend?.cn.language.name, + 'Content-Type': [ + req.backend?.cn.mediaType.name, + `charset="${req.backend?.cn.charset.name}"` + ].join('; '), + }); + res.statusMessage = req.backend?.cn.language.statusMessages.mediaTypeNotAcceptable() ?? ''; + res.end(response); + return; + } + + const responseBodyCharset = availableCharsets.find((l) => l.name === charsetCandidate); + if (typeof responseBodyCharset === 'undefined') { + const data = req.backend?.cn.language.bodies.encodingNotAcceptable(); + const responseRaw = req.backend?.cn.mediaType.serialize(data); + const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; + res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { + 'Content-Language': req.backend?.cn.language.name, + 'Content-Type': [ + req.backend?.cn.mediaType.name, + `charset="${req.backend?.cn.charset.name}"` + ].join('; '), + }); + res.statusMessage = req.backend?.cn.language.statusMessages.encodingNotAcceptable() ?? ''; + res.end(response); + return; + } + + req.cn.language = currentLanguage; + req.cn.mediaType = currentMediaType; + req.cn.charset = responseBodyCharset; + }; + + server.on('request', async (req: RequestContext, res) => { + adjustRequestForContentNegotiation(req, res); + + try { + adjustMethod(req); + } catch (errRaw) { + if (typeof errRaw !== 'undefined') { + const err= errRaw as HttpMiddlewareError; + const errBody = err.response.body; + if (typeof errBody !== 'undefined') { + res.writeHead(err.response.statusCode, { + ...(err.response.headers ?? {}), + 'Content-Language': req.backend.cn.language.name, + 'Content-Type': [ + req.backend.cn.mediaType.name, + `charset="${req.backend.cn.charset.name}"` + ].join('; '), + }); + res.statusMessage = err.response.statusMessage ?? ''; + const errBodySerialized = req.backend.cn.mediaType.serialize(errBody); + const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined; + res.end(errBodyEncoded); + return; + } + res.writeHead(err.response.statusCode, { + ...(err.response.headers ?? {}), + 'Content-Language': req.backend.cn.language.name, + }); + res.statusMessage = err.response.statusMessage ?? ''; + res.end(); + return; + } + } + + try { + adjustUrl(req); + } catch (errRaw) { + if (typeof errRaw !== 'undefined') { + const err= errRaw as HttpMiddlewareError; + const errBody = err.response.body; + if (typeof errBody !== 'undefined') { + res.writeHead(err.response.statusCode, { + ...(err.response.headers ?? {}), + 'Content-Language': req.backend.cn.language.name, + 'Content-Type': [ + req.backend.cn.mediaType.name, + `charset="${req.backend.cn.charset.name}"` + ].join('; '), + }); + res.statusMessage = err.response.statusMessage ?? ''; + const errBodySerialized = req.backend.cn.mediaType.serialize(errBody); + const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined; + res.end(errBodyEncoded); + return; + } + res.writeHead(err.response.statusCode, { + ...(err.response.headers ?? {}), + 'Content-Language': req.backend.cn.language.name, + }); + res.statusMessage = err.response.statusMessage ?? ''; + res.end(); + return; + } + } + + if (req.url === '/') { + const middlewareState = await handleGetRoot(req); + if (typeof middlewareState !== 'undefined') { + return; + } + + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: 'HEAD, GET' + }); + res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); + res.end(); + return; + } + + const [, resourceRouteName, resourceId = ''] = req.url.split('/') ?? []; + const resource = Array.from(req.backend.app.resources).find((r) => r.state!.routeName === resourceRouteName); + if (typeof resource === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); + res.end(); + return; + } + + req.resource = resource as BackendResource; + req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; + req.resourceId = resourceId; + + try { + await req.resource.dataSource.initialize(); + } catch (cause) { + throw new HttpMiddlewareError( + 'unableToInitializeResourceDataSource', + { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } + + const middlewares = getAllowedMiddlewares(resource, resourceId); + const middlewareState = await middlewares.reduce( + async (currentHandlerStatePromise, currentValue) => { + const [middlewareMethod, middleware, schema] = currentValue; + try { + const currentHandlerState = await currentHandlerStatePromise; + + if (req.method !== middlewareMethod) { + return currentHandlerState; + } + + if (typeof currentHandlerState !== 'undefined') { + return currentHandlerState; + } + + if (schema) { + const availableSerializers = Array.from(req.backend.app.mediaTypes); + const availableCharsets = Array.from(req.backend.app.charsets); + const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; + const fragments = contentTypeHeader.split(';'); + const mediaType = fragments[0]; + const charsetParam = fragments.map((s) => s.trim()) + .find((f) => f.startsWith('charset=')) ?? (mediaType.startsWith('text/') ? 'charset=utf-8' : 'charset=binary'); + const [_charsetKey, charsetRaw] = charsetParam.split('=').map((s) => s.trim()); + const charset = ( + ( + (charsetRaw.startsWith('"') && charsetRaw.endsWith('"')) + || (charsetRaw.startsWith("'") && charsetRaw.endsWith("'")) + ) + ? charsetRaw.slice(1, -1).trim() + : charsetRaw.trim() + ) + const deserializerPair = availableSerializers.find((l) => l.name === mediaType); + const encodingPair = availableCharsets.find((l) => l.name === charset); + (req as unknown as Record).body = await getBody(req, schema, encodingPair, deserializerPair); + } + + const result = await middleware(req); + + return Promise.resolve(result); + } catch (errRaw) { + // todo use error message key for each method + // TODO better error reporting, localizable messages + // TODO handle error handlers' errors + if (errRaw instanceof v.ValiError && Array.isArray(errRaw.issues)) { + return new HttpMiddlewareError('invalidResource', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + body: errRaw.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + }); + } + + return errRaw; + } + }, + Promise.resolve>(undefined) + ) as Awaited>; + + if (typeof middlewareState !== 'undefined') { + try { + if (middlewareState instanceof Error) { + throw middlewareState; + } + + const headers: Record = { + ...( + middlewareState.headers ?? {} + ), + 'Content-Language': req.cn.language.name + }; + + if (middlewareState instanceof StreamResponse) { + res.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); + middlewareState.stream.pipe(res); + middlewareState.stream.on('end', () => { + res.end(); + }); + return; + } + + if (middlewareState instanceof PlainResponse) { + let encoded: Buffer | undefined; + if (typeof middlewareState.body !== 'undefined') { + let serialized; + try { + serialized = req.cn.mediaType.serialize(middlewareState.body); + } catch (cause) { + throw new HttpMiddlewareError('unableToSerializeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers: { + 'Content-Language': req.backend.cn.language.name, + }, + }) + } + + try { + encoded = req.cn.charset.encode(serialized); + } catch (cause) { + throw new HttpMiddlewareError('unableToEncodeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers: { + 'Content-Language': req.backend.cn.language.name, + }, + }) + } + + headers['Content-Type'] = [ + req.cn.mediaType.name, + `charset=${req.cn.charset.name}` + ].join('; '); + } + + res.writeHead(middlewareState.statusCode, headers); + res.statusMessage = middlewareState.statusMessage ?? ''; + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + } + return; + } catch (finalErrRaw) { + const finalErr = finalErrRaw as HttpMiddlewareError; + const headers = finalErr.response.headers ?? {}; + let encoded: Buffer | undefined; + let serialized; + try { + serialized = typeof finalErr.response.body !== 'undefined' ? req.backend.cn.mediaType.serialize(finalErr.response.body) : undefined; + } catch (cause) { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + res.end(); + return; + } + + try { + encoded = typeof serialized !== 'undefined' ? req.backend.cn.charset.encode(serialized) : undefined; + } catch (cause) { + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + res.end(); + } + + headers['Content-Type'] = [ + req.backend.cn.mediaType.name, + `charset=${req.backend.cn.charset.name}` + ].join('; '); + + res.writeHead(finalErr.response.statusCode, headers); + res.statusMessage = finalErr.response.statusMessage ?? ''; + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + return; + } + } + + if (middlewares.length > 0) { + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: middlewares.map((m) => m[0]).join(', ') + }); + res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); + res.end(); + return; + } + + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); + res.end(); + return; + }); + + return server; +} diff --git a/src/backend/utils.ts b/src/backend/utils.ts new file mode 100644 index 0000000..70b8ab5 --- /dev/null +++ b/src/backend/utils.ts @@ -0,0 +1,28 @@ +import {IncomingMessage} from 'http'; +import {MediaType, Charset} from '../common'; +import {BaseSchema, parseAsync} from 'valibot'; + +export const getBody = ( + req: IncomingMessage, + schema: BaseSchema, + encodingPair?: Charset, + deserializer?: MediaType, +) => new Promise((resolve, reject) => { + let body = Buffer.from(''); + req.on('data', (chunk) => { + body = Buffer.concat([body, chunk]); + }); + req.on('end', async () => { + const bodyStr = encodingPair?.decode(body) ?? body.toString(); + try { + const bodyDeserialized = await parseAsync( + schema, + deserializer?.deserialize(bodyStr) ?? body, + {abortEarly: false}, + ); + resolve(bodyDeserialized); + } catch (err) { + reject(err); + } + }); +}); diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..ebb0e17 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,48 @@ +import * as applicationJson from '../common/media-types/application/json'; +import * as utf8 from '../common/charsets/utf-8'; +import * as en from '../common/languages/en'; +import {ApplicationState, Charset, Language, MediaType} from '../common'; + +export interface ClientState { + app: ApplicationState; + mediaType: MediaType; + charset: Charset; + language: Language; +} + +export interface ClientBuilder { + setLanguage(languageCode: ClientState['language']['name']): this; + setCharset(charset: ClientState['charset']['name']): this; + setMediaType(mediaType: ClientState['mediaType']['name']): this; +} + +export interface CreateClientParams { + app: ApplicationState; +} + +export const createClient = (params: CreateClientParams) => { + const clientState: ClientState = { + app: params.app, + mediaType: applicationJson, + charset: utf8, + language: en + }; + + return { + setMediaType(mediaTypeName) { + const mediaType = Array.from(clientState.app.mediaTypes).find((l) => l.name === mediaTypeName); + clientState.mediaType = mediaType ?? applicationJson; + return this; + }, + setCharset(charsetName) { + const charset = Array.from(clientState.app.charsets).find((l) => l.name === charsetName); + clientState.charset = charset ?? utf8; + return this; + }, + setLanguage(languageCode) { + const language = Array.from(clientState.app.languages).find((l) => l.name === languageCode); + clientState.language = language ?? en; + return this; + } + } satisfies ClientBuilder; +}; diff --git a/src/common/app.ts b/src/common/app.ts new file mode 100644 index 0000000..e60ec9d --- /dev/null +++ b/src/common/app.ts @@ -0,0 +1,16 @@ +import {Resource} from './resource'; +import {Language} from './language'; +import {MediaType} from './media-type'; +import {Charset} from './charset'; + +export interface ApplicationState { + name: string; + resources: Set>; + languages: Set; + mediaTypes: Set; + charsets: Set; +} + +export interface ApplicationParams { + name: string; +} diff --git a/src/encodings/index.ts b/src/common/charset.ts similarity index 56% rename from src/encodings/index.ts rename to src/common/charset.ts index 21fcf49..32c4cd8 100644 --- a/src/encodings/index.ts +++ b/src/common/charset.ts @@ -1,6 +1,4 @@ -export * as utf8 from './utf-8'; - -export interface EncodingPair { +export interface Charset { name: string; encode: (str: string) => Buffer; decode: (buf: Buffer) => string; diff --git a/src/common/charsets/index.ts b/src/common/charsets/index.ts new file mode 100644 index 0000000..54c3ff4 --- /dev/null +++ b/src/common/charsets/index.ts @@ -0,0 +1 @@ +export * as utf8 from './utf-8'; diff --git a/src/encodings/utf-8.ts b/src/common/charsets/utf-8.ts similarity index 100% rename from src/encodings/utf-8.ts rename to src/common/charsets/utf-8.ts diff --git a/src/common/data-source.ts b/src/common/data-source.ts new file mode 100644 index 0000000..58a5c46 --- /dev/null +++ b/src/common/data-source.ts @@ -0,0 +1,5 @@ +export interface BaseDataSource {} + +export interface GenerationStrategy { + (dataSource: D, ...args: unknown[]): Promise; +} diff --git a/src/common/index.ts b/src/common/index.ts new file mode 100644 index 0000000..0fe2c8a --- /dev/null +++ b/src/common/index.ts @@ -0,0 +1,5 @@ +export * from './app'; +export * from './charset'; +export * from './media-type'; +export * from './resource'; +export * from './language'; diff --git a/src/common.ts b/src/common/language.ts similarity index 82% rename from src/common.ts rename to src/common/language.ts index 003b8e1..d242bdb 100644 --- a/src/common.ts +++ b/src/common/language.ts @@ -1,4 +1,4 @@ -import {Resource} from './core'; +import {Resource} from './resource'; export type MessageBody = string | string[] | (string | string[])[]; @@ -6,6 +6,7 @@ export interface LanguageStatusMessageMap { unableToInitializeResourceDataSource(resource: Resource): string; unableToFetchResourceCollection(resource: Resource): string; unableToFetchResource(resource: Resource): string; + resourceIdNotGiven(resource: Resource): string; languageNotAcceptable(): string; encodingNotAcceptable(): string; mediaTypeNotAcceptable(): string; @@ -17,6 +18,7 @@ export interface LanguageStatusMessageMap { resourceFetched(resource: Resource): string; resourceNotFound(resource: Resource): string; deleteNonExistingResource(resource: Resource): string; + unableToCreateResource(resource: Resource): string; unableToGenerateIdFromResourceDataSource(resource: Resource): string; unableToEmplaceResource(resource: Resource): string; unableToSerializeResponse(): string; @@ -33,12 +35,14 @@ export interface LanguageStatusMessageMap { resourceReplaced(resource: Resource): string; } +export interface LanguageBodyMap { + languageNotAcceptable(): MessageBody; + encodingNotAcceptable(): MessageBody; + mediaTypeNotAcceptable(): MessageBody; +} + export interface Language { name: string, statusMessages: LanguageStatusMessageMap, - bodies: { - languageNotAcceptable(): MessageBody; - encodingNotAcceptable(): MessageBody; - mediaTypeNotAcceptable(): MessageBody; - } + bodies: LanguageBodyMap } diff --git a/src/languages/en/index.ts b/src/common/languages/en/index.ts similarity index 90% rename from src/languages/en/index.ts rename to src/common/languages/en/index.ts index 365e7ab..5bbdac5 100644 --- a/src/languages/en/index.ts +++ b/src/common/languages/en/index.ts @@ -1,5 +1,5 @@ -import {Resource} from '../../core'; -import {Language} from '../../common'; +import {Resource} from '../../resource'; +import {Language} from '../../language'; export const statusMessages = { unableToSerializeResponse(): string { @@ -85,6 +85,12 @@ export const statusMessages = { }, unableToEmplaceResource(resource: Resource): string { return `Unable To Emplace ${resource.state.itemName}`; + }, + resourceIdNotGiven(resource: Resource): string { + return `${resource.state.itemName} ID Not Given`; + }, + unableToCreateResource(resource: Resource): string { + return `Unable To Create ${resource.state.itemName}`; } } satisfies Language['statusMessages']; diff --git a/src/common/languages/index.ts b/src/common/languages/index.ts new file mode 100644 index 0000000..3cf5713 --- /dev/null +++ b/src/common/languages/index.ts @@ -0,0 +1 @@ +export * as en from './en'; diff --git a/src/common/media-type.ts b/src/common/media-type.ts new file mode 100644 index 0000000..a5733ca --- /dev/null +++ b/src/common/media-type.ts @@ -0,0 +1,5 @@ +export interface MediaType { + name: string; + serialize: (object: T) => string; + deserialize: (s: string) => T; +} diff --git a/src/serializers/application/json.ts b/src/common/media-types/application/json.ts similarity index 100% rename from src/serializers/application/json.ts rename to src/common/media-types/application/json.ts diff --git a/src/common/media-types/index.ts b/src/common/media-types/index.ts new file mode 100644 index 0000000..1ba7246 --- /dev/null +++ b/src/common/media-types/index.ts @@ -0,0 +1,2 @@ +export * as applicationJson from './application/json'; +export * as textJson from './application/json'; diff --git a/src/common/resource.ts b/src/common/resource.ts new file mode 100644 index 0000000..e45d61b --- /dev/null +++ b/src/common/resource.ts @@ -0,0 +1,149 @@ +import * as v from 'valibot'; +import {BaseDataSource, GenerationStrategy} from './data-source'; + +export interface ResourceIdConfig { + generationStrategy: GenerationStrategy; + serialize: (id: unknown) => string; + deserialize: (id: string) => v.Output; + schema: IdSchema; +} + +export interface ResourceState< + ItemName extends string = string, + RouteName extends string = string, + IdAttr extends string = string, + IdSchema extends v.BaseSchema = v.BaseSchema +> { + idAttr: IdAttr; + itemName: ItemName; + routeName: RouteName; + idConfig: ResourceIdConfig; + fullTextAttrs: Set; + canCreate: boolean; + canFetchCollection: boolean; + canFetchItem: boolean; + canPatch: boolean; + canEmplace: boolean; + canDelete: boolean; +} + +export interface Resource< + Schema extends v.BaseSchema = v.BaseSchema, + CurrentName extends string = string, + CurrentRouteName extends string = string, + CurrentIdAttr extends string = string, + IdSchema extends v.BaseSchema = v.BaseSchema +> { + dataSource?: unknown; + schema: Schema; + state: ResourceState; + id( + newIdAttr: NewIdAttr, + params: ResourceIdConfig + ): Resource; + fullText(fullTextAttr: string): this; + name(n: NewName): Resource; + route(n: NewRouteName): Resource; + 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 const resource = < + Schema extends v.BaseSchema, + CurrentName extends string = string, + CurrentRouteName extends string = string, + CurrentIdAttr extends string = string, + IdSchema extends v.BaseSchema = v.BaseSchema +>(schema: Schema): Resource => { + const resourceState = { + fullTextAttrs: new Set(), + canCreate: false, + canFetchCollection: false, + canFetchItem: false, + canPatch: false, + canEmplace: false, + canDelete: false, + } as ResourceState; + + return { + get state(): ResourceState { + return Object.freeze({ + ...resourceState + }) as unknown as ResourceState; + }, + canFetchCollection(b = true) { + resourceState.canFetchCollection = b; + return this; + }, + canFetchItem(b = true) { + resourceState.canFetchItem = b; + return this; + }, + canCreate(b = true) { + resourceState.canCreate = b; + return this; + }, + canPatch(b = true) { + resourceState.canPatch = b; + return this; + }, + canEmplace(b = true) { + resourceState.canEmplace = b; + return this; + }, + canDelete(b = true) { + resourceState.canDelete = b; + return this; + }, + id(newIdAttr: NewIdAttr, params: ResourceIdConfig) { + resourceState.idAttr = newIdAttr; + resourceState.idConfig = params; + return this as Resource; + }, + newId(dataSource: BaseDataSource) { + return resourceState?.idConfig?.generationStrategy?.(dataSource); + }, + fullText(fullTextAttr: string) { + if ( + schema.type === 'object' + && ( + schema as unknown as v.ObjectSchema< + Record, + undefined, + Record + > + ) + .entries[fullTextAttr]?.type === 'string' + ) { + resourceState.fullTextAttrs?.add(fullTextAttr); + return this; + } + + throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); + }, + name(n: NewName) { + resourceState.itemName = n; + return this as Resource; + }, + route(n: NewRouteName) { + resourceState.routeName = n; + return this as Resource; + }, + get idAttr() { + return resourceState.idAttr; + }, + get itemName() { + return resourceState.itemName; + }, + get routeName() { + return resourceState.routeName; + }, + get schema() { + return schema; + }, + } as Resource; +}; diff --git a/src/validation.ts b/src/common/validation.ts similarity index 92% rename from src/validation.ts rename to src/common/validation.ts index a4d50fa..abecf2c 100644 --- a/src/validation.ts +++ b/src/common/validation.ts @@ -1,6 +1,6 @@ import * as v from 'valibot'; export * from 'valibot'; -import { Resource } from './core'; +import { Resource } from './resource'; export const datelike = () => v.transform( v.union([ diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index fee0ed6..0000000 --- a/src/core.ts +++ /dev/null @@ -1,585 +0,0 @@ -import * as http from 'http'; -import * as https from 'https'; -import { constants } from 'http2'; -import { pluralize } from 'inflection'; -import * as v from 'valibot'; -import { SerializerPair } from './serializers'; -import { - handleCreateItem, handleDeleteItem, handleEmplaceItem, - handleGetCollection, - handleGetItem, - handleGetRoot, - handleHasMethodAndUrl, handlePatchItem, -} from './handlers'; -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'; -import {Language} from './common'; - -// TODO separate frontend and backend factory methods - -export interface DataSource { - initialize(): 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]>; - patch(id: string, data: Partial): Promise; -} - -export interface ApplicationParams { - name: string; - dataSource?: (resource: Resource) => DataSource; -} - -interface ResourceState { - idAttr: IdAttr; - itemName: string; - collectionName: string; - routeName: string; - idConfig: ResourceIdConfig; - fullTextAttrs: Set; - canCreate: boolean; - canFetchCollection: boolean; - canFetchItem: boolean; - canPatch: boolean; - canEmplace: boolean; - canDelete: boolean; -} - -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: ResourceSchema; - state: ResourceState; - id( - newIdAttr: NewIdAttr, - params: ResourceIdConfig - ): Resource; - fullText(fullTextAttr: string): this; - name(n: string): this; - collection(n: string): this; - route(n: string): 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 ResourceWithDataSource extends Resource { - dataSource: DataSource; -} - -interface GenerationStrategy { - (dataSource: DataSource, ...args: unknown[]): Promise; -} - -interface ResourceIdConfig { - generationStrategy: GenerationStrategy; - serialize: (id: unknown) => string; - deserialize: (id: string) => v.Output; - schema: IdSchema; -} - -const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { - const middlewares = [] as [string, Middleware][]; - if (mainResourceId === '') { - if (resource.state.canFetchCollection) { - middlewares.push(['GET', handleGetCollection]); - } - if (resource.state.canCreate) { - middlewares.push(['POST', handleCreateItem]); - } - return middlewares; - } - - if (resource.state.canFetchItem) { - middlewares.push(['GET', handleGetItem]); - } - if (resource.state.canEmplace) { - middlewares.push(['PUT', handleEmplaceItem]); - } - if (resource.state.canPatch) { - middlewares.push(['PATCH', handlePatchItem]); - } - if (resource.state.canDelete) { - middlewares.push(['DELETE', handleDeleteItem]); - } - - return middlewares; -}; - -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, - canFetchCollection: false, - canFetchItem: false, - canPatch: false, - canEmplace: false, - canDelete: false, - } as ResourceState; - - return { - get state(): ResourceState { - return Object.freeze({ - ...resourceState - }) as unknown as ResourceState; - }, - canFetchCollection(b = true) { - resourceState.canFetchCollection = b; - return this; - }, - canFetchItem(b = true) { - resourceState.canFetchItem = b; - return this; - }, - canCreate(b = true) { - resourceState.canCreate = b; - return this; - }, - canPatch(b = true) { - resourceState.canPatch = b; - return this; - }, - canEmplace(b = true) { - resourceState.canEmplace = b; - return this; - }, - canDelete(b = true) { - resourceState.canDelete = b; - return this; - }, - id(newIdAttr: NewIdAttr, params: ResourceIdConfig) { - resourceState.idAttr = newIdAttr; - resourceState.idConfig = params; - return this as Resource; - }, - newId(dataSource: DataSource) { - return resourceState?.idConfig?.generationStrategy?.(dataSource); - }, - fullText(fullTextAttr: string) { - if ( - schema.type === 'object' - && ( - schema as unknown as v.ObjectSchema< - Record, - undefined, - Record - > - ) - .entries[fullTextAttr]?.type === 'string' - ) { - resourceState.fullTextAttrs?.add(fullTextAttr); - return this; - } - - throw new Error(`Could not set attribute ${fullTextAttr} as fulltext.`); - }, - name(n: string) { - resourceState.itemName = n; - resourceState.collectionName = resourceState.collectionName ?? pluralize(n).toLowerCase(); - resourceState.routeName = resourceState.routeName ?? resourceState.collectionName; - return this; - }, - collection(n: string) { - resourceState.collectionName = n; - resourceState.routeName = resourceState.routeName ?? n; - return this; - }, - route(n: string) { - resourceState.routeName = n; - return this; - }, - get idAttr() { - return resourceState.idAttr; - }, - get collectionName() { - return resourceState.collectionName; - }, - get itemName() { - return resourceState.itemName; - }, - get routeName() { - return resourceState.routeName; - }, - get schema() { - return schema; - }, - } as Resource; -}; - -type RequestListenerWithReturn< - P extends unknown = unknown, - Q extends typeof http.IncomingMessage = typeof http.IncomingMessage, - R extends typeof http.ServerResponse = typeof http.ServerResponse -> = ( - ...args: Parameters> -) => P; - -interface HandlerState { - handled: boolean; -} - -export interface ApplicationState { - resources: Set>; - languages: Set; - serializers: Set; - encodings: Set; -} - -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; - responseBodyLanguage: Language; - responseBodyEncoding: EncodingPair; - responseBodyMediaType: SerializerPair; - errorResponseBodyLanguage: Language; - errorResponseBodyEncoding: EncodingPair; - errorResponseBodyMediaType: SerializerPair; - resource: ResourceWithDataSource; - resourceId: string; - query: URLSearchParams; -} - -export interface Middleware { - (args: MiddlewareArgs): RequestListenerWithReturn> -} - -interface CreateServerParams { - baseUrl?: string; - host?: string; - cert?: string; - key?: string; - requestTimeout?: number; -} - -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(serializerPair: SerializerPair): this; - language(language: Language): this; - encoding(encodingPair: EncodingPair): this; - resource(resRaw: Partial>): this; - createBackend(): Backend; - createClient(): Client; -} - -export const application = (appParams: ApplicationParams): Application => { - const appState: ApplicationState = { - resources: new Set>(), - languages: new Set(), - serializers: new Set(), - encodings: new Set(), - }; - - appState.languages.add(en); - appState.encodings.add(utf8); - appState.serializers.add(applicationJson); - - return { - contentType(serializerPair: SerializerPair) { - appState.serializers.add(serializerPair); - return this; - }, - encoding(encodingPair: EncodingPair) { - appState.encodings.add(encodingPair); - return this; - }, - language(language: Language) { - appState.languages.add(language); - 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.state!.itemName} must have a data source.`); - } - appState.resources.add(res as ResourceWithDataSource); - return this; - }, - createClient(): Client { - const clientState = { - contentType: applicationJson.name, - encoding: utf8.name, - language: en.name as string - }; - - 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.name, - 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 availableLanguages = Array.from(appState.languages); - const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? backendState.fallback.language; - const availableEncodings = Array.from(appState.encodings); - const encodingCandidate = negotiator.encoding(availableEncodings.map((l) => l.name)) ?? backendState.fallback.encoding; - const availableContentTypes = Array.from(appState.serializers); - const contentTypeCandidate = negotiator.mediaType(availableContentTypes.map((l) => l.name)) ?? backendState.fallback.serializer; - - const fallbackMessageCollection = en as Language; - const fallbackSerializerPair = applicationJson as SerializerPair; - const fallbackEncoding = utf8 as EncodingPair; - - const errorLanguageCode = backendState.errorHeaders.language ?? backendState.fallback.language; - const errorMessageCollection = availableLanguages.find((l) => l.name === errorLanguageCode) ?? fallbackMessageCollection; - - const errorContentType = backendState.errorHeaders.serializer ?? backendState.fallback.serializer; - const errorSerializerPair = availableContentTypes.find((l) => l.name === errorContentType) ?? fallbackSerializerPair; - - const errorEncodingKey = backendState.errorHeaders.encoding ?? backendState.fallback.encoding; - const errorEncoding = availableEncodings.find((l) => l.name === errorEncodingKey) ?? fallbackEncoding; - - // TODO refactor - const currentLanguageMessages = availableLanguages.find((l) => l.name === languageCandidate); - if (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 currentMediaType = availableContentTypes.find((l) => l.name === contentTypeCandidate); - if (typeof currentMediaType === '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 responseBodyEncodingEntry = availableEncodings.find((l) => l.name === encodingCandidate); - if (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, 'resource' | 'resourceId'> = { - handlerState: { - handled: false - }, - appState, - appParams, - backendState, - serverParams, - query, - responseBodyEncoding: responseBodyEncodingEntry, - responseBodyMediaType: currentMediaType, - responseBodyLanguage: currentLanguageMessages, - errorResponseBodyMediaType: errorSerializerPair, - errorResponseBodyEncoding: errorEncoding, - errorResponseBodyLanguage: 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; - } - - 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; - }); - - return server; - } - } satisfies Backend; - }, - }; -}; diff --git a/src/handlers.ts b/src/handlers.ts deleted file mode 100644 index 5bc0c36..0000000 --- a/src/handlers.ts +++ /dev/null @@ -1,835 +0,0 @@ -import { constants } from 'http2'; -import * as v from 'valibot'; -import {Middleware} from './core'; -import {getBody, getDeserializerObjects} from './utils'; -import {IncomingMessage, ServerResponse} from 'http'; - -export const handleHasMethodAndUrl: Middleware = ({ - errorResponseBodyLanguage, -}) => (req: IncomingMessage, res: ServerResponse) => { - if (!req.method) { - res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - 'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', - 'Content-Language': errorResponseBodyLanguage.name, - - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.methodNotAllowed(); - res.end(); - return { - handled: true - }; - } - - if (!req.url) { - res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = errorResponseBodyLanguage.statusMessages.badRequest(); - res.end(); - return { - handled: true - }; - } - - return { - handled: false - }; -}; - -export const handleGetRoot: Middleware = ({ - appState, - appParams, - serverParams, - responseBodyMediaType, - responseBodyLanguage, - responseBodyEncoding, - errorResponseBodyLanguage, -}) => (_req: IncomingMessage, res: ServerResponse) => { - const data = { - name: appParams.name - }; - - let serialized; - try { - serialized = responseBodyMediaType.serialize(data); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); - res.end(); - return { - handled: true, - }; - } - - let encoded; - try { - encoded = responseBodyEncoding.encode(serialized); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); - res.end(); - return { - handled: true, - }; - } - - const theHeaders: Record = { - 'Content-Type': responseBodyMediaType.name, - 'Content-Language': responseBodyLanguage.name, - 'Content-Encoding': responseBodyEncoding.name, - }; - - 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 - theHeaders['Link'] = availableResources - .map((r) => - `<${serverParams.baseUrl}/${r.state.routeName}>; rel="related"; name="${r.state.collectionName}"`, - ) - .join(', '); - } - res.writeHead(constants.HTTP_STATUS_OK, theHeaders); - res.statusMessage = responseBodyLanguage.statusMessages.ok(); - res.end(encoded); - return { - handled: true - }; -}; - -export const handleGetCollection: Middleware = ({ - resource, - responseBodyMediaType, - responseBodyLanguage, - responseBodyEncoding, - errorResponseBodyLanguage, - backendState, - query, -}) => async (_req: IncomingMessage, res: ServerResponse) => { - try { - await resource.dataSource.initialize(); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); - res.end(); - return { - handled: true - }; - } - - let data: v.Output[]; - let totalItemCount: number | undefined; - try { - // TODO querying mechanism - data = await resource.dataSource.getMultiple(query); // TODO paginated responses per resource - if (backendState.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { - totalItemCount = await resource.dataSource.getTotalCount(query); - } - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResourceCollection(resource); - res.end(); - return { - handled: true - }; - } - - let serialized; - try { - serialized = responseBodyMediaType.serialize(data); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); - res.end(); - return { - handled: true, - }; - } - - let encoded; - try { - encoded = responseBodyEncoding.encode(serialized); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); - res.end(); - return { - handled: true, - }; - } - - const headers: Record = { - 'Content-Type': responseBodyMediaType.name, - 'Content-Language': responseBodyLanguage.name, - 'Content-Encoding': responseBodyEncoding.name, - }; - - if (typeof totalItemCount !== 'undefined') { - headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); - } - - res.writeHead(constants.HTTP_STATUS_OK, headers); - res.statusMessage = responseBodyLanguage.statusMessages.resourceCollectionFetched(resource); - res.end(encoded); - return { - handled: true - }; -}; - -export const handleGetItem: Middleware = ({ - resourceId, - resource, - responseBodyMediaType, - responseBodyLanguage, - responseBodyEncoding, - errorResponseBodyLanguage, -}) => async (_req: IncomingMessage, res: ServerResponse) => { - try { - await resource.dataSource.initialize(); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); - res.end(); - return { - handled: true - }; - } - - let data: v.Output | null = null; - try { - data = await resource.dataSource.getById(resourceId); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource); - res.end(); - return { - handled: true - }; - } - - let serialized: string | null; - try { - serialized = data === null ? null : responseBodyMediaType.serialize(data); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); - res.end(); - return { - handled: true, - }; - } - - let encoded; - try { - encoded = serialized === null ? null : responseBodyEncoding.encode(serialized); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); - res.end(); - return { - handled: true, - }; - } - - if (encoded) { - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': responseBodyMediaType.name, - 'Content-Language': responseBodyLanguage.name, - 'Content-Encoding': responseBodyEncoding.name, - }); - res.statusMessage = responseBodyLanguage.statusMessages.resourceFetched(resource) - res.end(encoded); - return { - handled: true - }; - } - - res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.resourceNotFound(resource); - res.end(); - return { - handled: true - }; -}; - - -export const handleDeleteItem: Middleware = ({ - resource, - resourceId, - responseBodyLanguage, - errorResponseBodyLanguage, - backendState, -}) => async (_req: IncomingMessage, res: ServerResponse) => { - try { - await resource.dataSource.initialize(); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); - res.end(); - return { - handled: true - }; - } - - let existing: unknown | null; - try { - existing = await resource.dataSource.getById(resourceId); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource); - res.end(); - return { - handled: true - }; - } - - if (!existing && backendState.throws404OnDeletingNotFound) { - res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.deleteNonExistingResource(resource); - res.end(); - return { - handled: true - }; - } - - try { - if (existing) { - await resource.dataSource.delete(resourceId); - } - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeleteResource(resource); - res.end(); - return { - handled: true - }; - } - - res.writeHead(constants.HTTP_STATUS_NO_CONTENT, { - 'Content-Language': responseBodyLanguage.name, - }); - res.statusMessage = responseBodyLanguage.statusMessages.resourceDeleted(resource); - res.end(); - return { - handled: true - }; -}; - -export const handlePatchItem: Middleware = ({ - appState, - resource, - resourceId, - responseBodyMediaType, - responseBodyLanguage, - responseBodyEncoding, - errorResponseBodyLanguage, - errorResponseBodyMediaType, - errorResponseBodyEncoding, -}) => async (req: IncomingMessage, res: ServerResponse) => { - const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); - if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { - res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest(); - res.end(); - return { - handled: true - }; - } - - try { - await resource.dataSource.initialize(); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); - res.end(); - return { - handled: true - }; - } - - let existing: unknown | null; - try { - existing = await resource.dataSource.getById(resourceId); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToFetchResource(resource); - res.end(); - return { - handled: true - }; - } - - if (!existing) { - res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.patchNonExistingResource(resource); - res.end(); - return { - handled: true - }; - } - - let bodyDeserialized: unknown; - try { - const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema - bodyDeserialized = await getBody( - req, - requestBodyDeserializerPair, - requestBodyEncodingPair, - schema.type === 'object' - ? v.partial( - schema as v.ObjectSchema, - (schema as v.ObjectSchema).rest, - (schema as v.ObjectSchema).pipe - ) - : schema - ); - } catch (errRaw) { - const err = errRaw as v.ValiError; - const headers: Record = { - 'Content-Language': responseBodyLanguage.name, - }; - if (!Array.isArray(err.issues)) { - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) - res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResourcePatch(resource); - res.end(); - return { - handled: true, - }; - } - // TODO better error reporting, localizable messages - // TODO handle error handlers' errors - const serialized = errorResponseBodyMediaType.serialize( - err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )), - ); - const encoded = errorResponseBodyEncoding.encode(serialized); - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { - ...headers, - 'Content-Type': errorResponseBodyMediaType.name, - 'Content-Encoding': errorResponseBodyEncoding.name, - }) - res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResourcePatch(resource); - res.end(encoded); - return { - handled: true, - }; - } - - const params = bodyDeserialized as Record; - let newObject: v.Output | null; - try { - newObject = await resource.dataSource.patch(resourceId, params); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToPatchResource(resource); - res.end(); - return { - handled: true, - }; - } - - let serialized; - try { - serialized = responseBodyMediaType.serialize(newObject); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); - res.end(); - return { - handled: true, - }; - } - - let encoded; - try { - encoded = responseBodyEncoding.encode(serialized); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); - res.end(); - return { - handled: true, - }; - } - - res.writeHead(constants.HTTP_STATUS_OK, { - 'Content-Type': responseBodyMediaType.name, - 'Content-Language': responseBodyLanguage.name, - 'Content-Encoding': responseBodyEncoding.name, - }); - res.statusMessage = responseBodyLanguage.statusMessages.resourcePatched(resource); - res.end(encoded); - return { - handled: true - }; - - // TODO finish the rest of the handlers!!! -}; - -export const handleCreateItem: Middleware = ({ - appState, - serverParams, - backendState, - responseBodyMediaType, - responseBodyLanguage, - responseBodyEncoding, - errorResponseBodyLanguage, - errorResponseBodyMediaType, - errorResponseBodyEncoding, - resource, -}) => async (req: IncomingMessage, res: ServerResponse) => { - const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); - if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { - res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest(); - res.end(); - return { - handled: true - }; - } - - let bodyDeserialized: unknown; - try { - bodyDeserialized = await getBody( - req, - requestBodyDeserializerPair, - requestBodyEncodingPair, - resource.schema - ); - } catch (errRaw) { - const err = errRaw as v.ValiError; - const headers: Record = { - 'Content-Language': errorResponseBodyLanguage.name, - }; - if (!Array.isArray(err.issues)) { - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) - res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); - res.end(); - return { - handled: true, - }; - } - // TODO better error reporting, localizable messages - // TODO handle error handlers' errors - const serialized = errorResponseBodyMediaType.serialize( - err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )), - ); - const encoded = errorResponseBodyEncoding.encode(serialized); - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { - ...headers, - 'Content-Type': errorResponseBodyMediaType.name, - 'Content-Encoding': errorResponseBodyEncoding.name, - }) - res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); - res.end(encoded); - return { - handled: true, - }; - } - - try { - await resource.dataSource.initialize(); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); - res.end(); - return { - handled: true - }; - } - - //v.Output - - let newId; - let params: v.Output; - try { - newId = await resource.newId(resource.dataSource); - params = bodyDeserialized as Record; - params[resource.state.idAttr] = newId; - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.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(); - } - } catch { - // noop - // TODO - } - - let serialized; - try { - serialized = responseBodyMediaType.serialize(newObject); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); - res.end(); - return { - handled: true, - }; - } - - let encoded; - try { - encoded = responseBodyEncoding.encode(serialized); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); - res.end(); - return { - handled: true, - }; - } - - const headers: Record = { - 'Content-Type': responseBodyMediaType.name, - 'Content-Language': responseBodyLanguage.name, - 'Content-Encoding': responseBodyEncoding.name, - '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 = responseBodyLanguage.statusMessages.resourceCreated(resource); - res.end(encoded); - return { - handled: true - }; -} - -export const handleEmplaceItem: Middleware = ({ - appState, - serverParams, - responseBodyMediaType, - responseBodyLanguage, - responseBodyEncoding, - errorResponseBodyLanguage, - errorResponseBodyMediaType, - errorResponseBodyEncoding, - resource, - resourceId, - backendState, -}) => async (req: IncomingMessage, res: ServerResponse) => { - const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); - if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { - res.writeHead(constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToDeserializeRequest(); - res.end(); - return { - handled: true - }; - } - - let bodyDeserialized: unknown; - try { - const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema - bodyDeserialized = await getBody( - req, - requestBodyDeserializerPair, - requestBodyEncodingPair, - schema.type === 'object' - ? v.merge([ - schema as v.ObjectSchema, - v.object({ - [resource.state.idAttr]: v.transform( - v.any(), - input => resource.state.idConfig.serialize(input), - v.literal(resourceId) - ) - }) - ]) - : schema - ); - } catch (errRaw) { - const err = errRaw as v.ValiError; - const headers: Record = { - 'Content-Language': errorResponseBodyLanguage.name, - }; - if (!Array.isArray(err.issues)) { - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, headers) - res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); - res.end(); - return { - handled: true, - }; - } - // TODO better error reporting, localizable messages - // TODO handle error handlers' errors - const serialized = errorResponseBodyMediaType.serialize( - err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )), - ); - const encoded = errorResponseBodyEncoding.encode(serialized); - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, { - ...headers, - 'Content-Type': errorResponseBodyMediaType.name, - 'Content-Encoding': errorResponseBodyEncoding.name, - }) - res.statusMessage = errorResponseBodyLanguage.statusMessages.invalidResource(resource); - res.end(encoded); - return { - handled: true, - }; - } - - try { - await resource.dataSource.initialize(); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToInitializeResourceDataSource(resource); - res.end(); - return { - handled: true - }; - } - - let newObject: v.Output; - let isCreated: boolean; - try { - const params = bodyDeserialized as Record; - params[resource.state.idAttr] = resource.state.idConfig.deserialize(params[resource.state.idAttr] as string); - [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEmplaceResource(resource); - res.end(); - return { - handled: true - }; - } - - let serialized; - try { - serialized = responseBodyMediaType.serialize(newObject); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToSerializeResponse(); - res.end(); - return { - handled: true, - }; - } - - let encoded; - try { - encoded = responseBodyEncoding.encode(serialized); - } catch { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, { - 'Content-Language': errorResponseBodyLanguage.name, - }); - res.statusMessage = errorResponseBodyLanguage.statusMessages.unableToEncodeResponse(); - res.end(); - return { - handled: true, - }; - } - - const headers: Record = { - 'Content-Type': responseBodyMediaType.name, - 'Content-Language': responseBodyLanguage.name, - 'Content-Encoding': responseBodyEncoding.name, - }; - 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 - ? responseBodyLanguage.statusMessages.resourceCreated(resource) - : responseBodyLanguage.statusMessages.resourceReplaced(resource) - ); - res.end(encoded); - return { - handled: true - }; -} diff --git a/src/index.ts b/src/index.ts index 7189d7c..cf70a34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ -export * from './core'; -export * as validation from './validation'; -export * as dataSources from './data-sources'; -export * as serializers from './serializers'; -export * as encodings from './encodings'; +export * from './common'; +export * as validation from './common/validation'; + +export * as dataSources from './backend/data-sources'; + +export * as mediaTypes from './common/media-types'; +export * as charsets from './common/charsets'; +export * as languages from './common/languages'; + +export * from './app'; diff --git a/src/serializers/index.ts b/src/serializers/index.ts deleted file mode 100644 index 151723a..0000000 --- a/src/serializers/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * as applicationJson from './application/json'; -export * as textJson from './application/json'; - -export interface SerializerPair { - name: string; - serialize: (object: T) => string; - deserialize: (s: string) => T; -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 0db81af..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {IncomingMessage} from 'http'; -import {SerializerPair} from './serializers'; -import {BaseSchema, parseAsync} from 'valibot'; -import { URL } from 'url'; -import {EncodingPair} from './encodings'; -import {ApplicationState} from './core'; - -export const getDeserializerObjects = (appState: ApplicationState, req: IncomingMessage) => { - const availableSerializers = Array.from(appState.serializers); - const availableEncodings = Array.from(appState.encodings); - const deserializerPair = availableSerializers.find((l) => l.name === (req.headers['content-type'] ?? 'application/octet-stream')); - const encodingPair = availableEncodings.find((l) => l.name === (req.headers['content-encoding'] ?? 'utf-8')); - return { - deserializerPair, - encodingPair, - }; -}; - -export const getMethod = (req: IncomingMessage) => req.method!.trim().toUpperCase(); - -export const getUrl = (req: IncomingMessage, baseUrl?: string) => { - const urlObject = new URL(req.url!, 'http://localhost'); - const urlWithoutBaseRaw = urlObject.pathname.slice(baseUrl?.length ?? 0); - - return { - url: urlWithoutBaseRaw.length < 1 ? '/' : urlWithoutBaseRaw, - query: urlObject.searchParams, - }; -} - -export const getBody = ( - req: IncomingMessage, - deserializer: SerializerPair, - encodingPair: EncodingPair, - schema: BaseSchema -) => new Promise((resolve, reject) => { - let body = Buffer.from(''); - req.on('data', (chunk) => { - body = Buffer.concat([body, chunk]); - }); - req.on('end', async () => { - const bodyStr = encodingPair.decode(body); - try { - const bodyDeserialized = await parseAsync( - schema, - deserializer.deserialize(bodyStr), - {abortEarly: false}, - ); - resolve(bodyDeserialized); - } catch (err) { - reject(err); - } - }); -}); diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts index 7a13d1e..7b01643 100644 --- a/test/e2e/default.test.ts +++ b/test/e2e/default.test.ts @@ -19,23 +19,16 @@ import { import { join } from 'path'; -import { - application, - DataSource, - dataSources, - encodings, - Resource, - resource, - serializers, - validation as v, -} from '../../src'; import {request, Server} from 'http'; import {constants} from 'http2'; +import {DataSource} from '../../src/backend/data-source'; +import { dataSources } from '../../src/backend'; +import { application, resource, validation as v, Resource, charsets, mediaTypes } from '../../src'; const PORT = 3000; const HOST = 'localhost'; -const ACCEPT_ENCODING = encodings.utf8.name; -const ACCEPT = serializers.applicationJson.name; +const ACCEPT_CHARSET = charsets.utf8.name; +const ACCEPT = mediaTypes.applicationJson.name; const autoIncrement = async (dataSource: DataSource) => { const data = await dataSource.getMultiple() as Record[]; @@ -79,31 +72,33 @@ describe('yasumi', () => { }, v.never() )) - .name('Piano') + .name('Piano' as const) + .route('pianos' as const) .id('id' as const, { - generationStrategy: autoIncrement, + generationStrategy: autoIncrement as any, serialize: (id) => id?.toString() ?? '0', deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, schema: v.number(), - }) + }); }); let server: Server; beforeEach(() => { const app = application({ name: 'piano-service', - dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), }) - .contentType(serializers.applicationJson) - .encoding(encodings.utf8) + .mediaType(mediaTypes.applicationJson) + .charset(charsets.utf8) .resource(Piano); const backend = app - .createBackend() + .createBackend({ + dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), + }) .throws404OnDeletingNotFound(); server = backend.createServer({ - baseUrl: '/api' + basePath: '/api' }); return new Promise((resolve, reject) => { @@ -149,8 +144,8 @@ describe('yasumi', () => { path: '/api/pianos', method: 'GET', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, + 'Accept-Language': 'en', }, }, (res) => { @@ -159,7 +154,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - expect(res.headers).toHaveProperty('content-type', ACCEPT); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); res.on('data', (c) => { @@ -167,7 +162,7 @@ describe('yasumi', () => { }); res.on('close', () => { - const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); const resData = JSON.parse(resBufferJson); expect(resData).toEqual([]); resolve(); @@ -213,8 +208,7 @@ describe('yasumi', () => { path: '/api/pianos/1', method: 'GET', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, }, }, (res) => { @@ -223,7 +217,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - expect(res.headers).toHaveProperty('content-type', ACCEPT); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); res.on('data', (c) => { @@ -231,7 +225,7 @@ describe('yasumi', () => { }); res.on('close', () => { - const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); const resData = JSON.parse(resBufferJson); expect(resData).toEqual(data); resolve(); @@ -256,8 +250,7 @@ describe('yasumi', () => { path: '/api/pianos/2', method: 'GET', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, }, }, (res) => { @@ -312,8 +305,7 @@ describe('yasumi', () => { path: '/api/pianos', method: 'POST', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, 'Content-Type': ACCEPT, }, }, @@ -323,7 +315,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); - expect(res.headers).toHaveProperty('content-type', ACCEPT); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); res.on('data', (c) => { @@ -331,7 +323,7 @@ describe('yasumi', () => { }); res.on('close', () => { - const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); const resData = JSON.parse(resBufferJson); expect(resData).toEqual({ ...newData, @@ -385,8 +377,7 @@ describe('yasumi', () => { path: `/api/pianos/${data.id}`, method: 'PATCH', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, 'Content-Type': ACCEPT, }, }, @@ -396,7 +387,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - expect(res.headers).toHaveProperty('content-type', ACCEPT); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); res.on('data', (c) => { @@ -404,7 +395,7 @@ describe('yasumi', () => { }); res.on('close', () => { - const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); const resData = JSON.parse(resBufferJson); expect(resData).toEqual({ ...data, @@ -433,8 +424,7 @@ describe('yasumi', () => { path: '/api/pianos/2', method: 'PATCH', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, 'Content-Type': ACCEPT, }, }, @@ -491,8 +481,7 @@ describe('yasumi', () => { path: `/api/pianos/${newData.id}`, method: 'PUT', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, 'Content-Type': ACCEPT, }, }, @@ -502,7 +491,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); - expect(res.headers).toHaveProperty('content-type', ACCEPT); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); res.on('data', (c) => { @@ -510,7 +499,7 @@ describe('yasumi', () => { }); res.on('close', () => { - const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); const resData = JSON.parse(resBufferJson); expect(resData).toEqual(newData); resolve(); @@ -538,8 +527,7 @@ describe('yasumi', () => { path: `/api/pianos/${id}`, method: 'PUT', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, 'Content-Type': ACCEPT, }, }, @@ -549,7 +537,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED); - expect(res.headers).toHaveProperty('content-type', ACCEPT); + expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); res.on('data', (c) => { @@ -557,7 +545,7 @@ describe('yasumi', () => { }); res.on('close', () => { - const resBufferJson = resBuffer.toString(ACCEPT_ENCODING); + const resBufferJson = resBuffer.toString(ACCEPT_CHARSET); const resData = JSON.parse(resBufferJson); expect(resData).toEqual({ ...newData, @@ -609,8 +597,7 @@ describe('yasumi', () => { path: `/api/pianos/${data.id}`, method: 'DELETE', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, }, }, (res) => { @@ -640,8 +627,7 @@ describe('yasumi', () => { path: '/api/pianos/2', method: 'DELETE', headers: { - 'Accept': ACCEPT, - 'Accept-Encoding': ACCEPT_ENCODING, + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, }, }, (res) => { diff --git a/tsconfig.json b/tsconfig.json index b61dd10..7980d5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,10 @@ "include": ["src", "types"], "compilerOptions": { "module": "ESNext", - "lib": ["ESNext"], + "lib": [ + "ESNext", + "dom" + ], "importHelpers": true, "declaration": true, "sourceMap": true,