From 2484e62171fb76ab2c39b9d9d7874a2257eeab2d Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 16 May 2024 21:00:56 +0800 Subject: [PATCH] Extract server from backend Server lives on its own domain, with the default server being HTTP. --- packages/core/src/backend/common.ts | 14 + packages/core/src/backend/core.ts | 25 +- packages/core/src/backend/index.ts | 3 - .../core/src/backend/servers/http/core.ts | 859 ------------------ packages/core/src/common/media-type.ts | 8 +- packages/core/src/common/queries/common.ts | 6 +- packages/core/src/servers/http/core.ts | 828 +++++++++++++++++ .../decorators/backend/content-negotiation.ts | 6 +- .../servers/http/decorators/backend/index.ts | 4 +- .../http/decorators/backend/resource.ts | 6 +- .../servers/http/decorators/method/index.ts | 2 +- .../servers/http/decorators/url/base-path.ts | 4 +- .../servers/http/decorators/url/host.ts | 4 +- .../servers/http/decorators/url/index.ts | 4 +- .../servers/http/decorators/url/scheme.ts | 4 +- .../servers/http/handlers/default.ts | 4 +- .../servers/http/handlers/resource.ts | 12 +- .../src/{backend => }/servers/http/index.ts | 0 .../{backend => }/servers/http/response.ts | 4 +- .../src/{backend => }/servers/http/utils.ts | 2 +- .../core/test/features/decorators.test.ts | 15 +- .../core/test/handlers/http/default.test.ts | 13 +- .../test/handlers/http/error-handling.test.ts | 16 +- 23 files changed, 918 insertions(+), 925 deletions(-) delete mode 100644 packages/core/src/backend/servers/http/core.ts create mode 100644 packages/core/src/servers/http/core.ts rename packages/core/src/{backend => }/servers/http/decorators/backend/content-negotiation.ts (87%) rename packages/core/src/{backend => }/servers/http/decorators/backend/index.ts (78%) rename packages/core/src/{backend => }/servers/http/decorators/backend/resource.ts (80%) rename packages/core/src/{backend => }/servers/http/decorators/method/index.ts (92%) rename packages/core/src/{backend => }/servers/http/decorators/url/base-path.ts (66%) rename packages/core/src/{backend => }/servers/http/decorators/url/host.ts (64%) rename packages/core/src/{backend => }/servers/http/decorators/url/index.ts (90%) rename packages/core/src/{backend => }/servers/http/decorators/url/scheme.ts (65%) rename packages/core/src/{backend => }/servers/http/handlers/default.ts (98%) rename packages/core/src/{backend => }/servers/http/handlers/resource.ts (98%) rename packages/core/src/{backend => }/servers/http/index.ts (100%) rename packages/core/src/{backend => }/servers/http/response.ts (90%) rename packages/core/src/{backend => }/servers/http/utils.ts (94%) diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 6d95a9c..f31da14 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -9,6 +9,10 @@ import { } from '../common'; import {DataSource} from './data-source'; +export interface Server { + requestDecorator(requestDecorator: RequestDecorator): this; +} + export interface BackendState { app: ApplicationState; dataSource: DataSource; @@ -67,6 +71,16 @@ export interface Response { headers?: Record; } +export interface Backend { + showTotalItemCountOnGetCollection(b?: boolean): this; + showTotalItemCountOnCreateItem(b?: boolean): this; + checksSerializersOnDelete(b?: boolean): this; + throwsErrorOnDeletingNotFound(b?: boolean): this; + use(extender: (state: BackendState, t: this) => BackendExtended): BackendExtended; + createServer(type: string, options?: {}): T; + dataSource?: (resource: Resource) => T; +} + export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => { const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); return allowedMethods.join(','); diff --git a/packages/core/src/backend/core.ts b/packages/core/src/backend/core.ts index 954ca96..b63b188 100644 --- a/packages/core/src/backend/core.ts +++ b/packages/core/src/backend/core.ts @@ -1,15 +1,9 @@ -import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common'; -import {createServer, CreateServerParams, Server} from './servers/http'; -import {BackendState} from './common'; +import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE} from '../common'; +import {Backend, BackendState, Server} from './common'; import {DataSource} from './data-source'; -export interface Backend { - showTotalItemCountOnGetCollection(b?: boolean): this; - showTotalItemCountOnCreateItem(b?: boolean): this; - checksSerializersOnDelete(b?: boolean): this; - throwsErrorOnDeletingNotFound(b?: boolean): this; - createHttpServer(serverParams?: CreateServerParams): Server; - dataSource?: (resource: Resource) => T; +export interface BackendExtender = Backend, BB extends B = B> { + (state: BackendState, backend: B): BB; } export interface CreateBackendParams { @@ -49,8 +43,15 @@ export const createBackend = (params: CreateBackendParams) => { backendState.checksSerializersOnDelete = b; return this; }, - createHttpServer(serverParams = {} as CreateServerParams) { - return createServer(backendState, serverParams); + createServer(): Server { + return { + requestDecorator() { + return this; + }, + } satisfies Server; + }, + use(extender) { + return extender(backendState, this); }, } satisfies Backend; }; diff --git a/packages/core/src/backend/index.ts b/packages/core/src/backend/index.ts index 0fd1885..1243f85 100644 --- a/packages/core/src/backend/index.ts +++ b/packages/core/src/backend/index.ts @@ -1,6 +1,3 @@ export * from './core'; export * from './common'; export * from './data-source'; - -// TODO publish to separate library -export * as http from './servers/http'; diff --git a/packages/core/src/backend/servers/http/core.ts b/packages/core/src/backend/servers/http/core.ts deleted file mode 100644 index 25df2e7..0000000 --- a/packages/core/src/backend/servers/http/core.ts +++ /dev/null @@ -1,859 +0,0 @@ -import http, { createServer as httpCreateServer } from 'http'; -import { HTTPParser } from 'http-parser-js'; -import { createServer as httpCreateSecureServer } from 'https'; -import {constants,} from 'http2'; -import * as v from 'valibot'; -import { - AllowedMiddlewareSpecification, - BackendState, - Middleware, - RequestContext, - RequestDecorator, - Response, -} from '../../common'; -import { - BaseResourceType, - CanPatchSpec, - DELTA_SCHEMA, - getAcceptPatchString, - getAcceptPostString, - LanguageDefaultErrorStatusMessageKey, - PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES, - PatchContentType, queryMediaTypes, - Resource, -} from '../../../common'; -import {DataSource} from '../../data-source'; -import { - handleGetRoot, handleOptions, -} from './handlers/default'; -import { - handleCreateItem, - handleDeleteItem, - handleEmplaceItem, - handleGetCollection, - handleGetItem, - handlePatchItem, - handleQueryCollection, -} from './handlers/resource'; -import {getBody, isTextMediaType} from './utils'; -import {decorateRequestWithBackend} from './decorators/backend'; -import {decorateRequestWithMethod} from './decorators/method'; -import {decorateRequestWithUrl} from './decorators/url'; -import {ErrorPlainResponse, PlainResponse} from './response'; -import EventEmitter from 'events'; - -type RequiredResource = Required>['resource']; - -interface ResourceWithDataSource extends Omit { - dataSource: DataSource; -} - -interface ResourceRequestContext extends Omit { - resource: ResourceWithDataSource; -} - -declare module '../../common' { - interface RequestContext extends http.IncomingMessage { - body?: unknown; - } - - interface Middleware { - (req: Req, res: Res): undefined | Response | Promise; - } -} - -const constructPostSchema = (resource: Resource) => { - return resource.schema; -}; - -const constructPutSchema = (resource: Resource, mainResourceId?: string) => { - if (typeof mainResourceId === 'undefined') { - return resource.schema; - } - - const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; - const idAttr = resource.state.shared.get('idAttr') as string; - const idConfig = resource.state.shared.get('idConfig') as any; - return ( - schema.type === 'object' - ? v.merge([ - schema as v.ObjectSchema, - v.object({ - [idAttr]: v.transform( - v.any(), - input => idConfig!.serialize(input), - v.literal(mainResourceId) - ) - }) - ]) - : schema - ); -}; - -const constructPatchSchema = (resource: Resource) => { - const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; - - if (resource.schema.type !== 'object') { - return resource.schema; - } - - const schemaChoices = { - merge: v.partial( - schema as v.ObjectSchema, - (schema as v.ObjectSchema).rest, - (schema as v.ObjectSchema).pipe - ), - delta: v.array(DELTA_SCHEMA), - } - - const selectedSchemaChoices = Object.entries(schemaChoices) - .filter(([key]) => resource.state.canPatch[key as CanPatchSpec]) - .map(([, value]) => value); - - return v.union(selectedSchemaChoices); -}; -// TODO add a way to define custom middlewares -const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ - { - method: 'QUERY', - middleware: handleQueryCollection, - allowed: (resource) => resource.state.canFetchCollection, - }, - { - method: 'GET', - middleware: handleGetCollection, - allowed: (resource) => resource.state.canFetchCollection, - }, - { - method: 'POST', - middleware: handleCreateItem, - allowed: (resource) => resource.state.canCreate, - constructBodySchema: constructPostSchema, - }, -]; - -const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [ - { - method: 'GET', - middleware: handleGetItem, - allowed: (resource) => resource.state.canFetchItem, - }, - { - method: 'PUT', - middleware: handleEmplaceItem, - constructBodySchema: constructPutSchema, - allowed: (resource) => resource.state.canEmplace, - }, - { - method: 'PATCH', - middleware: handlePatchItem, - constructBodySchema: constructPatchSchema, - allowed: (resource) => resource.state.canPatch.merge || resource.state.canPatch.delta, - }, - { - method: 'DELETE', - middleware: handleDeleteItem, - allowed: (resource) => resource.state.canDelete, - }, -]; - -export interface CreateServerParams { - basePath?: string; - host?: string; - cert?: string; - key?: string; - requestTimeout?: number; - // CQRS - streamResponses?: boolean; -} - -class CqrsEventEmitter extends EventEmitter { - -} - -export type ErrorHandler = (req: RequestContext, res: http.ServerResponse) => (err?: E) => never; - -interface ServerState { - requestDecorators: Set; - defaultErrorHandler?: ErrorHandler; -} - -export interface Server { - readonly listening: boolean; - on(event: string, cb: (...args: unknown[]) => unknown): this; - close(callback?: (err?: Error) => void): this; - listen(...args: Parameters): this; - requestDecorator(requestDecorator: RequestDecorator): this; - defaultErrorHandler(errorHandler: ErrorHandler): this; -} - -export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { - const state: ServerState = { - requestDecorators: new Set(), - defaultErrorHandler: undefined, - }; - - const isHttps = 'key' in serverParams && 'cert' in serverParams; - const theRes = new CqrsEventEmitter(); - - http.METHODS.push('QUERY'); - const server = isHttps - ? httpCreateSecureServer({ - key: serverParams.key, - cert: serverParams.cert, - requestTimeout: serverParams.requestTimeout, - // TODO add custom methods - }) - : httpCreateServer({ - requestTimeout: serverParams.requestTimeout, - }); - - const handleMiddlewares = async (currentHandlerState: Awaited>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => { - const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware; - const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; - - if (effectiveMethod !== middlewareMethod) { - return currentHandlerState; - } - - if (typeof currentHandlerState !== 'undefined') { - return currentHandlerState; - } - - if (effectiveMethod === 'QUERY') { - const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; - const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); - const mediaType = fragments[0]; - const charsetParam = ( - fragments - .map((s) => s.trim()) - .find((f) => f.startsWith('charset=')) - - ?? ( - isTextMediaType(mediaType) - ? '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() - ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); - - const theBodyBuffer = await getBody(req); - const encodingPair = req.backend.app.charsets.get(charset); - if (typeof encodingPair === 'undefined') { - throw new ErrorPlainResponse('unableToDecodeResource', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - res: theRes, - }); - } - - const deserializerPair = Object.values(queryMediaTypes) - .find((a) => a.name === mediaType); - if (typeof deserializerPair === 'undefined') { - throw new ErrorPlainResponse( - 'unableToDeserializeRequest', - { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - res: theRes, - }, - ); - } - - const theBodyStr = encodingPair.decode(theBodyBuffer); - req.body = deserializerPair.deserialize(theBodyStr); - } else if (typeof constructBodySchema === 'function') { - const bodySchema = constructBodySchema(req.resource, req.resourceId); - const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; - const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); - const mediaType = fragments[0]; - const charsetParam = ( - fragments - .map((s) => s.trim()) - .find((f) => f.startsWith('charset=')) - - ?? ( - isTextMediaType(mediaType) - ? '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() - ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); - - if (effectiveMethod === 'POST' && PATCH_CONTENT_TYPES.includes(mediaType as PatchContentType)) { - throw new ErrorPlainResponse('invalidResource', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - res: theRes, - headers: { - 'Accept-Post': getAcceptPostString(req.backend.app.mediaTypes), - }, - }); - } - - if (effectiveMethod === 'PATCH') { - const isPatchEnabled = req.resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[mediaType as PatchContentType]]; - if (!isPatchEnabled) { - throw new ErrorPlainResponse('invalidResourcePatchType', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - res: theRes, - headers: { - 'Accept-Patch': getAcceptPatchString(req.resource.state.canPatch), - }, - }); - } - } - - const theBodyBuffer = await getBody(req); - const encodingPair = req.backend.app.charsets.get(charset); - if (typeof encodingPair === 'undefined') { - throw new ErrorPlainResponse('unableToDecodeResource', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - res: theRes, - }); - } - const deserializerPair = req.backend.app.mediaTypes.get(mediaType); - if (typeof deserializerPair === 'undefined') { - throw new ErrorPlainResponse('unableToDeserializeResource', { - statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, - res: theRes, - }); - } - const theBodyStr = encodingPair.decode(theBodyBuffer); - const theBody = deserializerPair.deserialize(theBodyStr); - try { - // for validation, I wonder why an empty object is returned for PATCH when both methods are enabled - req.body = await v.parseAsync(bodySchema, theBody, {abortEarly: false, abortPipeEarly: false}); - req.body = theBody; - } catch (errRaw) { - const err = errRaw as v.ValiError; - // todo use error message key for each method - // TODO better error reporting, localizable messages - // TODO handle error handlers' errors - if (Array.isArray(err.issues)) { - if (req.method === 'PATCH' && req.headers['content-type']?.startsWith('application/json-patch+json')) { - throw new ErrorPlainResponse('invalidResourcePatch', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - body: err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )), - res: theRes, - }); - } - - throw new ErrorPlainResponse('invalidResource', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - body: err.issues.map((i) => ( - `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` - )), - res: theRes, - }); - } - } - } - - const result = await middleware(req, theRes); - - // HEAD is just GET without the response body - if (req.method === 'HEAD' && result instanceof PlainResponse) { - const { body: _, ...etcResult } = result; - - return new PlainResponse({ - ...etcResult, - res: theRes, - }); - } - - return result; - }; - - const processRequest = (middlewares: AllowedMiddlewareSpecification[]) => async (req: ResourceRequestContext) => { - const { resource } = req; - if (typeof resource === 'undefined') { - throw new ErrorPlainResponse('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - res: theRes, - }); - } - - if (req.method === 'OPTIONS') { - return handleOptions(middlewares)(req, theRes); - } - - if (typeof resource.dataSource === 'undefined') { - throw new ErrorPlainResponse('unableToBindResourceDataSource', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res: theRes, - }); - } - - try { - await resource.dataSource.initialize(); - } catch (cause) { - throw new ErrorPlainResponse( - 'unableToInitializeResourceDataSource', - { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res: theRes, - } - ); - } - - const middlewareResponse = await middlewares.reduce>( - async (currentHandlerStatePromise, currentMiddleware) => { - const currentHandlerState = await currentHandlerStatePromise; - return await handleMiddlewares(currentHandlerState, currentMiddleware, req); - }, - Promise.resolve>(undefined) - ) as Awaited>; - - if (typeof middlewareResponse === 'undefined') { - throw new ErrorPlainResponse('resourceNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - res: theRes, - }); - } - - return middlewareResponse as Awaited> - }; - - const defaultRequestDecorators = [ - decorateRequestWithMethod, - decorateRequestWithUrl(serverParams), - decorateRequestWithBackend(backendState), - ]; - - const decorateRequest = async (reqRaw: http.IncomingMessage) => { - const effectiveRequestDecorators = [ - ...defaultRequestDecorators, - ...Array.from(state.requestDecorators), - ]; - - return await effectiveRequestDecorators.reduce( - async (resultRequestPromise, decorator) => { - const resultRequest = await resultRequestPromise; - const decoratedRequest = await decorator(resultRequest); - // TODO log decorators - return decoratedRequest; - }, - Promise.resolve(reqRaw as RequestContext) - ); - }; - - const handleResourceError = (processRequestErrRaw: Error) => (resourceReq: ResourceRequestContext, res: http.ServerResponse) => { - const finalErr = processRequestErrRaw as ErrorPlainResponse; - const headers = finalErr.headers ?? {}; - const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; - const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; - const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; - let encoded: Buffer | undefined; - let serialized; - - const body = finalErr.body ?? language.bodies[(finalErr.statusMessage ?? 'internalServerError') as LanguageDefaultErrorStatusMessageKey]; - try { - serialized = mediaType.serialize(body); - } catch (cause) { - handleError( - new ErrorPlainResponse('unableToSerializeResponse', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res, - cause, - }) - )(resourceReq, res); - return; - } - - try { - encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined; - } catch (cause) { - handleError( - new ErrorPlainResponse('unableToEncodeResponse', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res, - cause, - }) - )(resourceReq, res); - return; - } - - headers['Content-Type'] = [ - mediaType.name, - typeof serialized !== 'undefined' ? `charset=${charset.name}` : '', - ] - .filter((s) => s.length > 0) - .join('; '); - - res.statusMessage = language.statusMessages[ - finalErr.statusMessage ?? 'internalServerError' - ]?.replace(/\$RESOURCE/g, - resourceReq.resource.state.itemName); - res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); - }; - - const handleError = (err: Error) => (req: RequestContext, res: http.ServerResponse) => { - if ('resource' in req && typeof req.resource !== 'undefined') { - handleResourceError(err)(req as ResourceRequestContext, res); - return; - } - - const finalErr = err as ErrorPlainResponse; - const headers = finalErr.headers ?? {}; - const language = req.backend.cn.language; - const mediaType = req.backend.cn.mediaType; - const charset = req.backend.cn.charset; - - let encoded: Buffer | undefined; - let serialized; - const body = finalErr.body ?? language.bodies[(finalErr.statusMessage ?? 'internalServerError') as LanguageDefaultErrorStatusMessageKey]; - try { - serialized = mediaType.serialize(body); - } catch (cause) { - // TODO logging - res.statusMessage = language.statusMessages['unableToSerializeResponse']; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - return; - } - - try { - encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined; - } catch (cause) { - // TODO logging - res.statusMessage = language.statusMessages['unableToEncodeResponse']; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - return; - } - - headers['Content-Type'] = [ - mediaType.name, - typeof serialized !== 'undefined' ? `charset=${charset.name}` : '', - ] - .filter((s) => s.length > 0) - .join('; '); - - res.statusMessage = typeof finalErr.statusMessage !== 'undefined' ? language.statusMessages[finalErr.statusMessage] : ''; - res.writeHead( - finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - ) - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); - }; - - const handleResourceResponse = (resourceReq: ResourceRequestContext, res: http.ServerResponse) => (middlewareState: Response) => { - const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; - const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; - const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; - - const headers: Record = { - ...( - middlewareState.headers ?? {} - ), - 'Content-Language': language.name, - }; - if (middlewareState instanceof http.ServerResponse) { - // TODO streaming responses - middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); - return; - } - if (middlewareState instanceof PlainResponse) { - let encoded: Buffer | undefined; - if (typeof middlewareState.body !== 'undefined') { - let serialized; - try { - serialized = mediaType.serialize(middlewareState.body); - } catch (cause) { - const headers: Record = { - 'Content-Language': language.name, - }; - if (resourceReq.method === 'POST') { - headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) - .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) - .join(','); - } else if (resourceReq.method === 'PATCH') { - headers['Accept-Patch'] = Array.from(Object.entries(PATCH_CONTENT_MAP_TYPE)) - .filter(([, value]) => Object.keys(resourceReq.resource.state.canPatch).includes(value)) - .map(([contentType]) => contentType) - .join(','); - } - - handleError(new ErrorPlainResponse('unableToSerializeResponse', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - res, - }))(resourceReq, res); - return; - } - - try { - encoded = charset.encode(serialized); - } catch (cause) { - handleError(new ErrorPlainResponse('unableToEncodeResponse', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - res, - }))(resourceReq, res); - return; - } - - headers['Content-Type'] = [ - mediaType.name, - `charset=${charset.name}`, - ].join('; '); - } - - const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource.state.itemName) ?? ''; - res.writeHead(middlewareState.statusCode, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); - return; - } - - handleError(new ErrorPlainResponse('urlNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - res, - }))(resourceReq, res); - }; - - const handleResponse = (resourceReq: RequestContext, res: http.ServerResponse) => (middlewareState: Response) => { - if ('resource' in resourceReq && typeof resourceReq.resource !== 'undefined') { - handleResourceResponse(resourceReq as ResourceRequestContext, res)(middlewareState); - return; - } - - const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; - const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; - const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; - - const headers: Record = { - ...( - middlewareState.headers ?? {} - ), - 'Content-Language': language.name, - }; - if (middlewareState instanceof http.ServerResponse) { - // TODO streaming responses - middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); - return; - } - if (middlewareState instanceof PlainResponse) { - let encoded: Buffer | undefined; - if (typeof middlewareState.body !== 'undefined') { - let serialized; - try { - serialized = mediaType.serialize(middlewareState.body); - } catch (cause) { - const headers: Record = { - 'Content-Language': language.name, - }; - if (resourceReq.method === 'POST') { - headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) - .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) - .join(','); - } - - handleError(new ErrorPlainResponse('unableToSerializeResponse', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - res, - }))(resourceReq, res); - return; - } - - try { - encoded = charset.encode(serialized); - } catch (cause) { - handleError(new ErrorPlainResponse('unableToEncodeResponse', { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - headers, - res, - }))(resourceReq, res); - return; - } - - headers['Content-Type'] = [ - mediaType.name, - `charset=${charset.name}`, - ].join('; '); - } - - const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; - res.statusMessage = statusMessageKey ?? ''; - res.writeHead(middlewareState.statusCode, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); - return; - } - - handleError(new ErrorPlainResponse('urlNotFound', { - statusCode: constants.HTTP_STATUS_NOT_FOUND, - res, - }))(resourceReq, res); - }; - - const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { - const plainReq = await decorateRequest(reqRaw); // TODO add type safety here - - if (plainReq.url === '/' || plainReq.url === '') { - const response = await handleGetRoot(plainReq as ResourceRequestContext, theRes); - if (typeof response === 'undefined') { - handleError( - new ErrorPlainResponse('internalServerError', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res, - }) - )(reqRaw, res); - return; - } - handleResponse(plainReq as ResourceRequestContext, res)(response); - return; - } - - if (typeof plainReq.resource !== 'undefined') { - const resourceReq = plainReq as ResourceRequestContext; - // TODO custom middlewares - const effectiveMiddlewares = ( - typeof resourceReq.resourceId === 'string' - ? defaultItemMiddlewares - : defaultCollectionMiddlewares - ); - const middlewares = effectiveMiddlewares.filter((m) => m.allowed(resourceReq.resource)); - // TODO listen to res.on('response') - const processRequestFn = processRequest(middlewares); - let middlewareState: Response; - try { - middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this - } catch (processRequestErrRaw) { - // TODO add error handlers - handleError(processRequestErrRaw as Error)(resourceReq, res); - return; - } - - handleResponse(resourceReq, res)(middlewareState); - return; - } - - try { - state.defaultErrorHandler?.(reqRaw, res)(); - } catch (err) { - handleError(err as Error)(reqRaw, res); - return; - } - - handleError( - new ErrorPlainResponse('internalServerError', { - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - res, - }) - )(reqRaw, res); - }; - - // server.on('connection', (socket) => { - // if (!HTTPParser.methods.includes('QUERY')) { - // HTTPParser.methods.push('QUERY'); - // } - // const httpParserMut = (HTTPParser as unknown as Record); - // console.log(httpParserMut.methods); - // httpParserMut.socket = socket; - // httpParserMut.remove = () => { - // // noop - // }; - // httpParserMut.free = () => { - // // noop - // }; - // socket.parser = httpParserMut; - // }); - - // server.on('connection', (socket) => { - // let newLineOffset; - // let receiveBuffer = Buffer.from(''); - // const listeners = socket.listeners('data'); - // const oldListener = listeners[0]; - // - // function newListener(this: any, d, start, end) { - // console.log(d.slice(start, end).toString('utf-8')); - // - // receiveBuffer = Buffer.concat([receiveBuffer, d.slice(start, end)]); - // if ((newLineOffset = receiveBuffer.toString('ascii').indexOf('\n')) > -1) { - // var firstLineParts = receiveBuffer.slice(0, newLineOffset).toString().split(' '); - // firstLineParts[0] = firstLineParts[0].replace(/^QUERY$/ig, 'POST'); - // //firstLineParts[2] = firstLineParts[2].replace(/^ICE\//ig, 'HTTP/'); - // receiveBuffer = Buffer.concat([ - // Buffer.from( - // firstLineParts.join(' ') + '\r\n' + - // 'Content-Length: 9007199254740992\r\n' - // ), - // receiveBuffer.slice(newLineOffset + 1) - // ]); - // } - // - // console.log(receiveBuffer.toString('utf-8')); - // oldListener.apply(this, d); - // } - // - // socket.on('data', newListener); - // socket.off('data', oldListener); - // }); - - // TODO create server directly from net.createConnection() - server.on('request', handleRequest); - - return { - get listening() { return server.listening }, - listen(...args: Parameters) { - server.listen(...args); - return this; - }, - close(callback?: (err?: Error) => void) { - server.close(callback); - return this; - }, - on(...args: Parameters) { - server.on(args[0], args[1]); - return this; - }, - requestDecorator(requestDecorator: RequestDecorator) { - state.requestDecorators.add(requestDecorator); - return this; - }, - defaultErrorHandler(errorHandler: ErrorHandler) { - state.defaultErrorHandler = errorHandler; - return this; - } - } satisfies Server; - // return server; -} diff --git a/packages/core/src/common/media-type.ts b/packages/core/src/common/media-type.ts index 3332340..9abd4ee 100644 --- a/packages/core/src/common/media-type.ts +++ b/packages/core/src/common/media-type.ts @@ -1,12 +1,12 @@ export interface MediaType< Name extends string = string, T extends object = object, - SerializeOpts extends unknown[] = [], - DeserializeOpts extends unknown[] = [] + SerializeOpts extends {} = {}, + DeserializeOpts extends {} = {} > { name: Name; - serialize: (object: T, ...args: SerializeOpts) => string; - deserialize: (s: string, ...args: DeserializeOpts) => T; + serialize: (object: T, args?: SerializeOpts) => string; + deserialize: (s: string, args?: DeserializeOpts) => T; } export const FALLBACK_MEDIA_TYPE = { diff --git a/packages/core/src/common/queries/common.ts b/packages/core/src/common/queries/common.ts index b1500eb..fe462d9 100644 --- a/packages/core/src/common/queries/common.ts +++ b/packages/core/src/common/queries/common.ts @@ -39,6 +39,8 @@ export interface QueryAndGrouping { expressions: QueryOrGrouping[]; } +export type Query = QueryAndGrouping; + export interface QueryMediaType< Name extends string = string, SerializeOptions extends {} = {}, @@ -46,6 +48,6 @@ export interface QueryMediaType< > extends MediaType< Name, QueryAndGrouping, - [SerializeOptions], - [DeserializeOptions] + SerializeOptions, + DeserializeOptions > {} diff --git a/packages/core/src/servers/http/core.ts b/packages/core/src/servers/http/core.ts new file mode 100644 index 0000000..7d80dfd --- /dev/null +++ b/packages/core/src/servers/http/core.ts @@ -0,0 +1,828 @@ +import http, { createServer as httpCreateServer } from 'http'; +import { createServer as httpCreateSecureServer } from 'https'; +import {constants,} from 'http2'; +import * as v from 'valibot'; +import EventEmitter from 'events'; +import { + AllowedMiddlewareSpecification, + Backend, + BackendState, + Middleware, + RequestContext, + RequestDecorator, + Response, + Server, + DataSource, +} from '../../backend'; +import { + BaseResourceType, + CanPatchSpec, + DELTA_SCHEMA, + getAcceptPatchString, + getAcceptPostString, + LanguageDefaultErrorStatusMessageKey, + PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES, + PatchContentType, queryMediaTypes, + Resource, +} from '../../common'; +import { + handleGetRoot, handleOptions, +} from './handlers/default'; +import { + handleCreateItem, + handleDeleteItem, + handleEmplaceItem, + handleGetCollection, + handleGetItem, + handlePatchItem, + handleQueryCollection, +} from './handlers/resource'; +import {getBody, isTextMediaType} from './utils'; +import {decorateRequestWithBackend} from './decorators/backend'; +import {decorateRequestWithMethod} from './decorators/method'; +import {decorateRequestWithUrl} from './decorators/url'; +import {ErrorPlainResponse, PlainResponse} from './response'; + +type RequiredResource = Required>['resource']; + +interface ResourceWithDataSource extends Omit { + dataSource: DataSource; +} + +interface ResourceRequestContext extends Omit { + resource: ResourceWithDataSource; +} + +export interface HttpServer extends Server { + readonly listening: boolean; + on(event: string, cb: (...args: unknown[]) => unknown): this; + close(callback?: (err?: Error) => void): this; + listen(...args: Parameters): this; + defaultErrorHandler(errorHandler: ErrorHandler): this; +} + +declare module '../../backend' { + interface RequestContext extends http.IncomingMessage { + body?: unknown; + } + + interface Middleware { + (req: Req, res: Res): undefined | Response | Promise; + } + + interface Backend { + createServer(type: 'http', options?: CreateServerParams): T; + } +} + +const constructPostSchema = (resource: Resource) => { + return resource.schema; +}; + +const constructPutSchema = (resource: Resource, mainResourceId?: string) => { + if (typeof mainResourceId === 'undefined') { + return resource.schema; + } + + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; + const idAttr = resource.state.shared.get('idAttr') as string; + const idConfig = resource.state.shared.get('idConfig') as any; + return ( + schema.type === 'object' + ? v.merge([ + schema as v.ObjectSchema, + v.object({ + [idAttr]: v.transform( + v.any(), + input => idConfig!.serialize(input), + v.literal(mainResourceId) + ) + }) + ]) + : schema + ); +}; + +const constructPatchSchema = (resource: Resource) => { + const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema : resource.schema; + + if (resource.schema.type !== 'object') { + return resource.schema; + } + + const schemaChoices = { + merge: v.partial( + schema as v.ObjectSchema, + (schema as v.ObjectSchema).rest, + (schema as v.ObjectSchema).pipe + ), + delta: v.array(DELTA_SCHEMA), + } + + const selectedSchemaChoices = Object.entries(schemaChoices) + .filter(([key]) => resource.state.canPatch[key as CanPatchSpec]) + .map(([, value]) => value); + + return v.union(selectedSchemaChoices); +}; +// TODO add a way to define custom middlewares +const defaultCollectionMiddlewares: AllowedMiddlewareSpecification[] = [ + { + method: 'QUERY', + middleware: handleQueryCollection, + allowed: (resource) => resource.state.canFetchCollection, + }, + { + method: 'GET', + middleware: handleGetCollection, + allowed: (resource) => resource.state.canFetchCollection, + }, + { + method: 'POST', + middleware: handleCreateItem, + allowed: (resource) => resource.state.canCreate, + constructBodySchema: constructPostSchema, + }, +]; + +const defaultItemMiddlewares: AllowedMiddlewareSpecification[] = [ + { + method: 'GET', + middleware: handleGetItem, + allowed: (resource) => resource.state.canFetchItem, + }, + { + method: 'PUT', + middleware: handleEmplaceItem, + constructBodySchema: constructPutSchema, + allowed: (resource) => resource.state.canEmplace, + }, + { + method: 'PATCH', + middleware: handlePatchItem, + constructBodySchema: constructPatchSchema, + allowed: (resource) => resource.state.canPatch.merge || resource.state.canPatch.delta, + }, + { + method: 'DELETE', + middleware: handleDeleteItem, + allowed: (resource) => resource.state.canDelete, + }, +]; + +export interface CreateServerParams { + basePath?: string; + host?: string; + cert?: string; + key?: string; + requestTimeout?: number; + // CQRS + streamResponses?: boolean; +} + +class CqrsEventEmitter extends EventEmitter { + +} + +export type ErrorHandler = (req: RequestContext, res: http.ServerResponse) => (err?: E) => never; + +interface ServerState { + requestDecorators: Set; + defaultErrorHandler?: ErrorHandler; +} + +export const httpExtender = (backendState: BackendState, backend: Backend) => { + const originalCreateServer = backend.createServer; + backend.createServer = (type: 'http', serverParamsRaw = {}) => { + const theServerRaw = originalCreateServer(type, serverParamsRaw); + if (type !== 'http') { + return theServerRaw; + } + + const serverParams = serverParamsRaw as CreateServerParams; + const state: ServerState = { + requestDecorators: new Set(), + defaultErrorHandler: undefined, + }; + + const theServer = { + ...theServerRaw, + get listening() { return server.listening }, + listen(...args: Parameters) { + server.listen(...args); + return this; + }, + close(callback?: (err?: Error) => void) { + server.close(callback); + return this; + }, + on(...args: Parameters) { + server.on(args[0], args[1]); + return this; + }, + requestDecorator(requestDecorator: RequestDecorator) { + state.requestDecorators.add(requestDecorator); + return this; + }, + defaultErrorHandler(errorHandler: ErrorHandler) { + state.defaultErrorHandler = errorHandler; + return this; + } + } as HttpServer; + + const isHttps = 'key' in serverParams && 'cert' in serverParams; + const theRes = new CqrsEventEmitter(); + + http.METHODS.push('QUERY'); + const server = isHttps + ? httpCreateSecureServer({ + key: serverParams.key, + cert: serverParams.cert, + requestTimeout: serverParams.requestTimeout, + // TODO add custom methods + }) + : httpCreateServer({ + requestTimeout: serverParams.requestTimeout, + }); + + const handleMiddlewares = async (currentHandlerState: Awaited>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => { + const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware; + const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; + + if (effectiveMethod !== middlewareMethod) { + return currentHandlerState; + } + + if (typeof currentHandlerState !== 'undefined') { + return currentHandlerState; + } + + if (effectiveMethod === 'QUERY') { + const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; + const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); + const mediaType = fragments[0]; + const charsetParam = ( + fragments + .map((s) => s.trim()) + .find((f) => f.startsWith('charset=')) + + ?? ( + isTextMediaType(mediaType) + ? '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() + ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); + + const theBodyBuffer = await getBody(req); + const encodingPair = req.backend.app.charsets.get(charset); + if (typeof encodingPair === 'undefined') { + throw new ErrorPlainResponse('unableToDecodeResource', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + }); + } + + const deserializerPair = Object.values(queryMediaTypes) + .find((a) => a.name === mediaType); + if (typeof deserializerPair === 'undefined') { + throw new ErrorPlainResponse( + 'unableToDeserializeRequest', + { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + }, + ); + } + + const theBodyStr = encodingPair.decode(theBodyBuffer); + req.body = deserializerPair.deserialize(theBodyStr); + } else if (typeof constructBodySchema === 'function') { + const bodySchema = constructBodySchema(req.resource, req.resourceId); + const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; + const fragments = contentTypeHeader.replace(/\s+/g, ' ').split(';'); + const mediaType = fragments[0]; + const charsetParam = ( + fragments + .map((s) => s.trim()) + .find((f) => f.startsWith('charset=')) + + ?? ( + isTextMediaType(mediaType) + ? '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() + ) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary'); + + if (effectiveMethod === 'POST' && PATCH_CONTENT_TYPES.includes(mediaType as PatchContentType)) { + throw new ErrorPlainResponse('invalidResource', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + headers: { + 'Accept-Post': getAcceptPostString(req.backend.app.mediaTypes), + }, + }); + } + + if (effectiveMethod === 'PATCH') { + const isPatchEnabled = req.resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[mediaType as PatchContentType]]; + if (!isPatchEnabled) { + throw new ErrorPlainResponse('invalidResourcePatchType', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + headers: { + 'Accept-Patch': getAcceptPatchString(req.resource.state.canPatch), + }, + }); + } + } + + const theBodyBuffer = await getBody(req); + const encodingPair = req.backend.app.charsets.get(charset); + if (typeof encodingPair === 'undefined') { + throw new ErrorPlainResponse('unableToDecodeResource', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + }); + } + const deserializerPair = req.backend.app.mediaTypes.get(mediaType); + if (typeof deserializerPair === 'undefined') { + throw new ErrorPlainResponse('unableToDeserializeResource', { + statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, + res: theRes, + }); + } + const theBodyStr = encodingPair.decode(theBodyBuffer); + const theBody = deserializerPair.deserialize(theBodyStr); + try { + // for validation, I wonder why an empty object is returned for PATCH when both methods are enabled + req.body = await v.parseAsync(bodySchema, theBody, {abortEarly: false, abortPipeEarly: false}); + req.body = theBody; + } catch (errRaw) { + const err = errRaw as v.ValiError; + // todo use error message key for each method + // TODO better error reporting, localizable messages + // TODO handle error handlers' errors + if (Array.isArray(err.issues)) { + if (req.method === 'PATCH' && req.headers['content-type']?.startsWith('application/json-patch+json')) { + throw new ErrorPlainResponse('invalidResourcePatch', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + body: err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + res: theRes, + }); + } + + throw new ErrorPlainResponse('invalidResource', { + statusCode: constants.HTTP_STATUS_BAD_REQUEST, + body: err.issues.map((i) => ( + `${i.path?.map((p) => p.key)?.join('.') ?? i.reason}: ${i.message}` + )), + res: theRes, + }); + } + } + } + + const result = await middleware(req, theRes); + + // HEAD is just GET without the response body + if (req.method === 'HEAD' && result instanceof PlainResponse) { + const { body: _, ...etcResult } = result; + + return new PlainResponse({ + ...etcResult, + res: theRes, + }); + } + + return result; + }; + + const processRequest = (middlewares: AllowedMiddlewareSpecification[]) => async (req: ResourceRequestContext) => { + const { resource } = req; + if (typeof resource === 'undefined') { + throw new ErrorPlainResponse('resourceNotFound', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + res: theRes, + }); + } + + if (req.method === 'OPTIONS') { + return handleOptions(middlewares)(req, theRes); + } + + if (typeof resource.dataSource === 'undefined') { + throw new ErrorPlainResponse('unableToBindResourceDataSource', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res: theRes, + }); + } + + try { + await resource.dataSource.initialize(); + } catch (cause) { + throw new ErrorPlainResponse( + 'unableToInitializeResourceDataSource', + { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res: theRes, + } + ); + } + + const middlewareResponse = await middlewares.reduce>( + async (currentHandlerStatePromise, currentMiddleware) => { + const currentHandlerState = await currentHandlerStatePromise; + return await handleMiddlewares(currentHandlerState, currentMiddleware, req); + }, + Promise.resolve>(undefined) + ) as Awaited>; + + if (typeof middlewareResponse === 'undefined') { + throw new ErrorPlainResponse('resourceNotFound', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + res: theRes, + }); + } + + return middlewareResponse as Awaited> + }; + + const defaultRequestDecorators = [ + decorateRequestWithMethod, + decorateRequestWithUrl(serverParams), + decorateRequestWithBackend(backendState), + ]; + + const decorateRequest = async (reqRaw: http.IncomingMessage) => { + const effectiveRequestDecorators = [ + ...defaultRequestDecorators, + ...Array.from(state.requestDecorators), + ]; + + return await effectiveRequestDecorators.reduce( + async (resultRequestPromise, decorator) => { + const resultRequest = await resultRequestPromise; + const decoratedRequest = await decorator(resultRequest); + // TODO log decorators + return decoratedRequest; + }, + Promise.resolve(reqRaw as RequestContext) + ); + }; + + const handleResourceError = (processRequestErrRaw: Error) => (resourceReq: ResourceRequestContext, res: http.ServerResponse) => { + const finalErr = processRequestErrRaw as ErrorPlainResponse; + const headers = finalErr.headers ?? {}; + const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; + const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; + const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; + let encoded: Buffer | undefined; + let serialized; + + const body = finalErr.body ?? language.bodies[(finalErr.statusMessage ?? 'internalServerError') as LanguageDefaultErrorStatusMessageKey]; + try { + serialized = mediaType.serialize(body); + } catch (cause) { + handleError( + new ErrorPlainResponse('unableToSerializeResponse', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + cause, + }) + )(resourceReq, res); + return; + } + + try { + encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined; + } catch (cause) { + handleError( + new ErrorPlainResponse('unableToEncodeResponse', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + cause, + }) + )(resourceReq, res); + return; + } + + headers['Content-Type'] = [ + mediaType.name, + typeof serialized !== 'undefined' ? `charset=${charset.name}` : '', + ] + .filter((s) => s.length > 0) + .join('; '); + + res.statusMessage = language.statusMessages[ + finalErr.statusMessage ?? 'internalServerError' + ]?.replace(/\$RESOURCE/g, + resourceReq.resource.state.itemName); + res.writeHead(finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + }; + + const handleError = (err: Error) => (req: RequestContext, res: http.ServerResponse) => { + if ('resource' in req && typeof req.resource !== 'undefined') { + handleResourceError(err)(req as ResourceRequestContext, res); + return; + } + + const finalErr = err as ErrorPlainResponse; + const headers = finalErr.headers ?? {}; + const language = req.backend.cn.language; + const mediaType = req.backend.cn.mediaType; + const charset = req.backend.cn.charset; + + let encoded: Buffer | undefined; + let serialized; + const body = finalErr.body ?? language.bodies[(finalErr.statusMessage ?? 'internalServerError') as LanguageDefaultErrorStatusMessageKey]; + try { + serialized = mediaType.serialize(body); + } catch (cause) { + // TODO logging + res.statusMessage = language.statusMessages['unableToSerializeResponse']; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + res.end(); + return; + } + + try { + encoded = typeof serialized !== 'undefined' ? charset.encode(serialized) : undefined; + } catch (cause) { + // TODO logging + res.statusMessage = language.statusMessages['unableToEncodeResponse']; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + res.end(); + return; + } + + headers['Content-Type'] = [ + mediaType.name, + typeof serialized !== 'undefined' ? `charset=${charset.name}` : '', + ] + .filter((s) => s.length > 0) + .join('; '); + + res.statusMessage = typeof finalErr.statusMessage !== 'undefined' ? language.statusMessages[finalErr.statusMessage] : ''; + res.writeHead( + finalErr.statusCode ?? constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + ) + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + }; + + const handleResourceResponse = (resourceReq: ResourceRequestContext, res: http.ServerResponse) => (middlewareState: Response) => { + const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; + const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; + const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; + + const headers: Record = { + ...( + middlewareState.headers ?? {} + ), + 'Content-Language': language.name, + }; + if (middlewareState instanceof http.ServerResponse) { + // TODO streaming responses + middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); + return; + } + if (middlewareState instanceof PlainResponse) { + let encoded: Buffer | undefined; + if (typeof middlewareState.body !== 'undefined') { + let serialized; + try { + serialized = mediaType.serialize(middlewareState.body); + } catch (cause) { + const headers: Record = { + 'Content-Language': language.name, + }; + if (resourceReq.method === 'POST') { + headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) + .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) + .join(','); + } else if (resourceReq.method === 'PATCH') { + headers['Accept-Patch'] = Array.from(Object.entries(PATCH_CONTENT_MAP_TYPE)) + .filter(([, value]) => Object.keys(resourceReq.resource.state.canPatch).includes(value)) + .map(([contentType]) => contentType) + .join(','); + } + + handleError(new ErrorPlainResponse('unableToSerializeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + try { + encoded = charset.encode(serialized); + } catch (cause) { + handleError(new ErrorPlainResponse('unableToEncodeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + headers['Content-Type'] = [ + mediaType.name, + `charset=${charset.name}`, + ].join('; '); + } + + const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource.state.itemName) ?? ''; + res.writeHead(middlewareState.statusCode, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + return; + } + + handleError(new ErrorPlainResponse('urlNotFound', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + res, + }))(resourceReq, res); + }; + + const handleResponse = (resourceReq: RequestContext, res: http.ServerResponse) => (middlewareState: Response) => { + if ('resource' in resourceReq && typeof resourceReq.resource !== 'undefined') { + handleResourceResponse(resourceReq as ResourceRequestContext, res)(middlewareState); + return; + } + + const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; + const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; + const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; + + const headers: Record = { + ...( + middlewareState.headers ?? {} + ), + 'Content-Language': language.name, + }; + if (middlewareState instanceof http.ServerResponse) { + // TODO streaming responses + middlewareState.writeHead(constants.HTTP_STATUS_ACCEPTED, headers); + return; + } + if (middlewareState instanceof PlainResponse) { + let encoded: Buffer | undefined; + if (typeof middlewareState.body !== 'undefined') { + let serialized; + try { + serialized = mediaType.serialize(middlewareState.body); + } catch (cause) { + const headers: Record = { + 'Content-Language': language.name, + }; + if (resourceReq.method === 'POST') { + headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()) + .filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t)) + .join(','); + } + + handleError(new ErrorPlainResponse('unableToSerializeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + try { + encoded = charset.encode(serialized); + } catch (cause) { + handleError(new ErrorPlainResponse('unableToEncodeResponse', { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + headers, + res, + }))(resourceReq, res); + return; + } + + headers['Content-Type'] = [ + mediaType.name, + `charset=${charset.name}`, + ].join('; '); + } + + const statusMessageKey = middlewareState.statusMessage ? language.statusMessages[middlewareState.statusMessage] : undefined; + res.statusMessage = statusMessageKey ?? ''; + res.writeHead(middlewareState.statusCode, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + return; + } + + handleError(new ErrorPlainResponse('urlNotFound', { + statusCode: constants.HTTP_STATUS_NOT_FOUND, + res, + }))(resourceReq, res); + }; + + const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { + const plainReq = await decorateRequest(reqRaw); // TODO add type safety here + + if (plainReq.url === '/' || plainReq.url === '') { + const response = await handleGetRoot(plainReq as ResourceRequestContext, theRes); + if (typeof response === 'undefined') { + handleError( + new ErrorPlainResponse('internalServerError', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + }) + )(reqRaw, res); + return; + } + handleResponse(plainReq as ResourceRequestContext, res)(response); + return; + } + + if (typeof plainReq.resource !== 'undefined') { + const resourceReq = plainReq as ResourceRequestContext; + // TODO custom middlewares + const effectiveMiddlewares = ( + typeof resourceReq.resourceId === 'string' + ? defaultItemMiddlewares + : defaultCollectionMiddlewares + ); + const middlewares = effectiveMiddlewares.filter((m) => m.allowed(resourceReq.resource)); + // TODO listen to res.on('response') + const processRequestFn = processRequest(middlewares); + let middlewareState: Response; + try { + middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this + } catch (processRequestErrRaw) { + // TODO add error handlers + handleError(processRequestErrRaw as Error)(resourceReq, res); + return; + } + + handleResponse(resourceReq, res)(middlewareState); + return; + } + + try { + state.defaultErrorHandler?.(reqRaw, res)(); + } catch (err) { + handleError(err as Error)(reqRaw, res); + return; + } + + handleError( + new ErrorPlainResponse('internalServerError', { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + res, + }) + )(reqRaw, res); + }; + + server.on('request', handleRequest); + + return theServer; + }; + + return backend; +}; diff --git a/packages/core/src/backend/servers/http/decorators/backend/content-negotiation.ts b/packages/core/src/servers/http/decorators/backend/content-negotiation.ts similarity index 87% rename from packages/core/src/backend/servers/http/decorators/backend/content-negotiation.ts rename to packages/core/src/servers/http/decorators/backend/content-negotiation.ts index 0c50c21..4c6a9f0 100644 --- a/packages/core/src/backend/servers/http/decorators/backend/content-negotiation.ts +++ b/packages/core/src/servers/http/decorators/backend/content-negotiation.ts @@ -1,8 +1,8 @@ -import {ContentNegotiation} from '../../../../../common'; -import {RequestDecorator} from '../../../../common'; +import {ContentNegotiation} from '../../../../common'; +import {RequestDecorator} from '../../../../backend'; import Negotiator from 'negotiator'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { cn: Partial; } diff --git a/packages/core/src/backend/servers/http/decorators/backend/index.ts b/packages/core/src/servers/http/decorators/backend/index.ts similarity index 78% rename from packages/core/src/backend/servers/http/decorators/backend/index.ts rename to packages/core/src/servers/http/decorators/backend/index.ts index 4942219..94f7406 100644 --- a/packages/core/src/backend/servers/http/decorators/backend/index.ts +++ b/packages/core/src/servers/http/decorators/backend/index.ts @@ -1,8 +1,8 @@ -import {BackendState, ParamRequestDecorator} from '../../../../common'; +import {BackendState, ParamRequestDecorator} from '../../../../backend'; import {decorateRequestWithContentNegotiation} from './content-negotiation'; import {decorateRequestWithResource} from './resource'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { backend: BackendState; } diff --git a/packages/core/src/backend/servers/http/decorators/backend/resource.ts b/packages/core/src/servers/http/decorators/backend/resource.ts similarity index 80% rename from packages/core/src/backend/servers/http/decorators/backend/resource.ts rename to packages/core/src/servers/http/decorators/backend/resource.ts index b3a300e..353b0f5 100644 --- a/packages/core/src/backend/servers/http/decorators/backend/resource.ts +++ b/packages/core/src/servers/http/decorators/backend/resource.ts @@ -1,7 +1,7 @@ -import {Resource} from '../../../../../common'; -import {RequestDecorator} from '../../../../common'; +import {Resource} from '../../../../common'; +import {RequestDecorator} from '../../../../backend'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { resource?: Resource; resourceId?: string; diff --git a/packages/core/src/backend/servers/http/decorators/method/index.ts b/packages/core/src/servers/http/decorators/method/index.ts similarity index 92% rename from packages/core/src/backend/servers/http/decorators/method/index.ts rename to packages/core/src/servers/http/decorators/method/index.ts index 3c61f84..960ea0e 100644 --- a/packages/core/src/backend/servers/http/decorators/method/index.ts +++ b/packages/core/src/servers/http/decorators/method/index.ts @@ -1,4 +1,4 @@ -import {RequestDecorator} from '../../../../common'; +import {RequestDecorator} from '../../../../backend'; const METHOD_SPOOF_HEADER_NAME = 'x-original-method' as const; const METHOD_SPOOF_ORIGINAL_METHOD = 'POST' as const; diff --git a/packages/core/src/backend/servers/http/decorators/url/base-path.ts b/packages/core/src/servers/http/decorators/url/base-path.ts similarity index 66% rename from packages/core/src/backend/servers/http/decorators/url/base-path.ts rename to packages/core/src/servers/http/decorators/url/base-path.ts index 3243a8a..507dfbb 100644 --- a/packages/core/src/backend/servers/http/decorators/url/base-path.ts +++ b/packages/core/src/servers/http/decorators/url/base-path.ts @@ -1,6 +1,6 @@ -import {ParamRequestDecorator} from '../../../../common'; +import {ParamRequestDecorator} from '../../../../backend'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { basePath: string; } diff --git a/packages/core/src/backend/servers/http/decorators/url/host.ts b/packages/core/src/servers/http/decorators/url/host.ts similarity index 64% rename from packages/core/src/backend/servers/http/decorators/url/host.ts rename to packages/core/src/servers/http/decorators/url/host.ts index 1755a9b..fe79483 100644 --- a/packages/core/src/backend/servers/http/decorators/url/host.ts +++ b/packages/core/src/servers/http/decorators/url/host.ts @@ -1,6 +1,6 @@ -import {ParamRequestDecorator} from '../../../../common'; +import {ParamRequestDecorator} from '../../../../backend'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { host: string; } diff --git a/packages/core/src/backend/servers/http/decorators/url/index.ts b/packages/core/src/servers/http/decorators/url/index.ts similarity index 90% rename from packages/core/src/backend/servers/http/decorators/url/index.ts rename to packages/core/src/servers/http/decorators/url/index.ts index 422735a..a92abcf 100644 --- a/packages/core/src/backend/servers/http/decorators/url/index.ts +++ b/packages/core/src/servers/http/decorators/url/index.ts @@ -1,10 +1,10 @@ -import {ParamRequestDecorator} from '../../../../common'; +import {ParamRequestDecorator} from '../../../../backend'; import {CreateServerParams} from '../../core'; import {decorateRequestWithScheme} from './scheme'; import {decorateRequestWithHost} from './host'; import {decorateRequestWithBasePath} from './base-path'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { rawUrl?: string; query: URLSearchParams; diff --git a/packages/core/src/backend/servers/http/decorators/url/scheme.ts b/packages/core/src/servers/http/decorators/url/scheme.ts similarity index 65% rename from packages/core/src/backend/servers/http/decorators/url/scheme.ts rename to packages/core/src/servers/http/decorators/url/scheme.ts index ea91944..5729490 100644 --- a/packages/core/src/backend/servers/http/decorators/url/scheme.ts +++ b/packages/core/src/servers/http/decorators/url/scheme.ts @@ -1,6 +1,6 @@ -import {ParamRequestDecorator} from '../../../../common'; +import {ParamRequestDecorator} from '../../../../backend'; -declare module '../../../../common' { +declare module '../../../../backend' { interface RequestContext { scheme: string; } diff --git a/packages/core/src/backend/servers/http/handlers/default.ts b/packages/core/src/servers/http/handlers/default.ts similarity index 98% rename from packages/core/src/backend/servers/http/handlers/default.ts rename to packages/core/src/servers/http/handlers/default.ts index aa7bff6..5e0bebc 100644 --- a/packages/core/src/backend/servers/http/handlers/default.ts +++ b/packages/core/src/servers/http/handlers/default.ts @@ -1,8 +1,8 @@ import {constants} from 'http2'; -import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '../../../common'; +import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '../../../backend'; import {LinkMap} from '../utils'; import {PlainResponse, ErrorPlainResponse} from '../response'; -import {getAcceptPatchString, getAcceptPostString} from '../../../../common'; +import {getAcceptPatchString, getAcceptPostString} from '../../../common'; export const handleGetRoot: Middleware = (req, res) => { const { backend, basePath } = req; diff --git a/packages/core/src/backend/servers/http/handlers/resource.ts b/packages/core/src/servers/http/handlers/resource.ts similarity index 98% rename from packages/core/src/backend/servers/http/handlers/resource.ts rename to packages/core/src/servers/http/handlers/resource.ts index d8dfb49..8427511 100644 --- a/packages/core/src/backend/servers/http/handlers/resource.ts +++ b/packages/core/src/servers/http/handlers/resource.ts @@ -1,15 +1,15 @@ import { constants } from 'http2'; import * as v from 'valibot'; -import {Middleware} from '../../../common'; -import {ErrorPlainResponse, PlainResponse} from '../response'; import assert from 'assert'; +import {Middleware} from '../../../backend'; import { - applyDelta, DataSourceQuery, + applyDelta, Query, Delta, PATCH_CONTENT_MAP_TYPE, PatchContentType, queryMediaTypes, -} from '../../../../common'; +} from '../../../common'; +import {ErrorPlainResponse, PlainResponse} from '../response'; // TODO add handleQueryCollection() @@ -24,7 +24,7 @@ export const handleQueryCollection: Middleware = async (req, res) => { let totalItemCount: number | undefined; try { // check which attributes have specifics on the queries (e.g. fuzzy search on strings) - const dataSourceQuery = body as DataSourceQuery; + const dataSourceQuery = body as Query; data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); @@ -67,7 +67,7 @@ export const handleGetCollection: Middleware = async (req, res) => { try { // check which attributes have specifics on the queries (e.g. fuzzy search on strings) const dataSourceQuery = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( - query.toString() + query.toString(), // TODO compute processEntry options based on resource attribute metadata (e.g. fulltext, queryable attributes - firstname, lastname, middlename) ); data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource diff --git a/packages/core/src/backend/servers/http/index.ts b/packages/core/src/servers/http/index.ts similarity index 100% rename from packages/core/src/backend/servers/http/index.ts rename to packages/core/src/servers/http/index.ts diff --git a/packages/core/src/backend/servers/http/response.ts b/packages/core/src/servers/http/response.ts similarity index 90% rename from packages/core/src/backend/servers/http/response.ts rename to packages/core/src/servers/http/response.ts index c0b9404..ad0ef7c 100644 --- a/packages/core/src/backend/servers/http/response.ts +++ b/packages/core/src/servers/http/response.ts @@ -1,5 +1,5 @@ -import {Language, LanguageStatusMessageMap} from '../../../common'; -import {MiddlewareResponseError, Response} from '../../common'; +import {Language, LanguageStatusMessageMap} from '../../common'; +import {MiddlewareResponseError, Response} from '../../backend'; interface PlainResponseParams extends Response { body?: T; diff --git a/packages/core/src/backend/servers/http/utils.ts b/packages/core/src/servers/http/utils.ts similarity index 94% rename from packages/core/src/backend/servers/http/utils.ts rename to packages/core/src/servers/http/utils.ts index c9c522c..6443284 100644 --- a/packages/core/src/backend/servers/http/utils.ts +++ b/packages/core/src/servers/http/utils.ts @@ -1,5 +1,5 @@ import {IncomingMessage} from 'http'; -import {PATCH_CONTENT_TYPES} from '../../../common'; +import {PATCH_CONTENT_TYPES} from '../../common'; export const isTextMediaType = (mediaType: string) => ( mediaType.startsWith('text/') diff --git a/packages/core/test/features/decorators.test.ts b/packages/core/test/features/decorators.test.ts index f1824ec..4582ea4 100644 --- a/packages/core/test/features/decorators.test.ts +++ b/packages/core/test/features/decorators.test.ts @@ -1,7 +1,8 @@ import {describe, afterAll, beforeAll, it} from 'vitest'; import {Application, application, resource, Resource, validation as v} from '../../src/common'; -import {Backend, DataSource, RequestContext} from '../../src/backend'; +import {Backend, DataSource, RequestContext, Server} from '../../src/backend'; import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils'; +import {httpExtender, HttpServer} from '../../src/servers/http'; const PORT = 3001; const HOST = '127.0.0.1'; @@ -17,7 +18,7 @@ describe('decorators', () => { let app: Application; let dataSource: DataSource; let backend: Backend; - let server: ReturnType; + let server: HttpServer; let client: TestClient; beforeAll(() => { @@ -44,11 +45,13 @@ describe('decorators', () => { dataSource = new DummyDataSource(); - backend = app.createBackend({ - dataSource, - }); + backend = app + .createBackend({ + dataSource, + }) + .use(httpExtender); - server = backend.createHttpServer({ + server = backend.createServer('http' as const, { basePath: BASE_PATH }); diff --git a/packages/core/test/handlers/http/default.test.ts b/packages/core/test/handlers/http/default.test.ts index 6015a30..f147071 100644 --- a/packages/core/test/handlers/http/default.test.ts +++ b/packages/core/test/handlers/http/default.test.ts @@ -18,6 +18,7 @@ import { Application, } from '../../../src/common'; import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../../utils'; +import {httpExtender, HttpServer} from '../../../src/servers/http'; const PORT = 3000; const HOST = '127.0.0.1'; @@ -35,7 +36,7 @@ describe('happy path', () => { let app: Application; let dataSource: DataSource; let backend: Backend; - let server: ReturnType; + let server: HttpServer; let client: TestClient; beforeAll(() => { @@ -62,11 +63,13 @@ describe('happy path', () => { dataSource = new DummyDataSource(); - backend = app.createBackend({ - dataSource, - }); + backend = app + .createBackend({ + dataSource, + }) + .use(httpExtender); - server = backend.createHttpServer({ + server = backend.createServer('http', { basePath: BASE_PATH }); diff --git a/packages/core/test/handlers/http/error-handling.test.ts b/packages/core/test/handlers/http/error-handling.test.ts index 6a404e4..56c3808 100644 --- a/packages/core/test/handlers/http/error-handling.test.ts +++ b/packages/core/test/handlers/http/error-handling.test.ts @@ -5,7 +5,8 @@ import { beforeEach, describe, expect, - it, vi, + it, + vi, } from 'vitest'; import {constants} from 'http2'; import {Backend, DataSource} from '../../../src/backend'; @@ -18,6 +19,7 @@ import { TEST_LANGUAGE, dummyGenerationStrategy, } from '../../utils'; +import {httpExtender, HttpServer} from '../../../src/servers/http'; const PORT = 3001; const HOST = '127.0.0.1'; @@ -35,7 +37,7 @@ describe('error handling', () => { let app: Application; let dataSource: DataSource; let backend: Backend; - let server: ReturnType; + let server: HttpServer; let client: TestClient; beforeAll(() => { @@ -62,11 +64,13 @@ describe('error handling', () => { dataSource = new DummyDataSource(); - backend = app.createBackend({ - dataSource, - }); + backend = app + .createBackend({ + dataSource, + }) + .use(httpExtender); - server = backend.createHttpServer({ + server = backend.createServer('http', { basePath: BASE_PATH });