Server lives on its own domain, with the default server being HTTP.master
@@ -9,6 +9,10 @@ import { | |||||
} from '../common'; | } from '../common'; | ||||
import {DataSource} from './data-source'; | import {DataSource} from './data-source'; | ||||
export interface Server { | |||||
requestDecorator(requestDecorator: RequestDecorator): this; | |||||
} | |||||
export interface BackendState { | export interface BackendState { | ||||
app: ApplicationState; | app: ApplicationState; | ||||
dataSource: DataSource; | dataSource: DataSource; | ||||
@@ -67,6 +71,16 @@ export interface Response { | |||||
headers?: Record<string, string>; | headers?: Record<string, string>; | ||||
} | } | ||||
export interface Backend<T extends DataSource = DataSource> { | |||||
showTotalItemCountOnGetCollection(b?: boolean): this; | |||||
showTotalItemCountOnCreateItem(b?: boolean): this; | |||||
checksSerializersOnDelete(b?: boolean): this; | |||||
throwsErrorOnDeletingNotFound(b?: boolean): this; | |||||
use<BackendExtended extends this>(extender: (state: BackendState, t: this) => BackendExtended): BackendExtended; | |||||
createServer<T extends Server>(type: string, options?: {}): T; | |||||
dataSource?: (resource: Resource) => T; | |||||
} | |||||
export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => { | export const getAllowString = (middlewares: AllowedMiddlewareSpecification[]) => { | ||||
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | ||||
return allowedMethods.join(','); | return allowedMethods.join(','); | ||||
@@ -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'; | import {DataSource} from './data-source'; | ||||
export interface Backend<T extends DataSource = DataSource> { | |||||
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<D extends DataSource = DataSource, B extends Backend<D> = Backend<D>, BB extends B = B> { | |||||
(state: BackendState, backend: B): BB; | |||||
} | } | ||||
export interface CreateBackendParams<T extends DataSource = DataSource> { | export interface CreateBackendParams<T extends DataSource = DataSource> { | ||||
@@ -49,8 +43,15 @@ export const createBackend = (params: CreateBackendParams) => { | |||||
backendState.checksSerializersOnDelete = b; | backendState.checksSerializersOnDelete = b; | ||||
return this; | 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; | } satisfies Backend; | ||||
}; | }; |
@@ -1,6 +1,3 @@ | |||||
export * from './core'; | export * from './core'; | ||||
export * from './common'; | export * from './common'; | ||||
export * from './data-source'; | export * from './data-source'; | ||||
// TODO publish to separate library | |||||
export * as http from './servers/http'; |
@@ -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<Pick<RequestContext, 'resource'>>['resource']; | |||||
interface ResourceWithDataSource extends Omit<RequiredResource, 'dataSource'> { | |||||
dataSource: DataSource; | |||||
} | |||||
interface ResourceRequestContext extends Omit<RequestContext, 'resource'> { | |||||
resource: ResourceWithDataSource; | |||||
} | |||||
declare module '../../common' { | |||||
interface RequestContext extends http.IncomingMessage { | |||||
body?: unknown; | |||||
} | |||||
interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext, Res extends NodeJS.EventEmitter = NodeJS.EventEmitter> { | |||||
(req: Req, res: Res): undefined | Response | Promise<undefined | Response>; | |||||
} | |||||
} | |||||
const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => { | |||||
return resource.schema; | |||||
}; | |||||
const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>, mainResourceId?: string) => { | |||||
if (typeof mainResourceId === 'undefined') { | |||||
return resource.schema; | |||||
} | |||||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : 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<any>, | |||||
v.object({ | |||||
[idAttr]: v.transform( | |||||
v.any(), | |||||
input => idConfig!.serialize(input), | |||||
v.literal(mainResourceId) | |||||
) | |||||
}) | |||||
]) | |||||
: schema | |||||
); | |||||
}; | |||||
const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => { | |||||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | |||||
if (resource.schema.type !== 'object') { | |||||
return resource.schema; | |||||
} | |||||
const schemaChoices = { | |||||
merge: v.partial( | |||||
schema as v.ObjectSchema<any>, | |||||
(schema as v.ObjectSchema<any>).rest, | |||||
(schema as v.ObjectSchema<any>).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<RequestContext>) => <E extends Error = Error>(err?: E) => never; | |||||
interface ServerState { | |||||
requestDecorators: Set<RequestDecorator>; | |||||
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<http.Server['listen']>): this; | |||||
requestDecorator(requestDecorator: RequestDecorator): this; | |||||
defaultErrorHandler(errorHandler: ErrorHandler): this; | |||||
} | |||||
export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { | |||||
const state: ServerState = { | |||||
requestDecorators: new Set<RequestDecorator>(), | |||||
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<ReturnType<Middleware>>, 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<ReturnType<Middleware>>( | |||||
async (currentHandlerStatePromise, currentMiddleware) => { | |||||
const currentHandlerState = await currentHandlerStatePromise; | |||||
return await handleMiddlewares(currentHandlerState, currentMiddleware, req); | |||||
}, | |||||
Promise.resolve<ReturnType<Middleware>>(undefined) | |||||
) as Awaited<ReturnType<Middleware>>; | |||||
if (typeof middlewareResponse === 'undefined') { | |||||
throw new ErrorPlainResponse('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
res: theRes, | |||||
}); | |||||
} | |||||
return middlewareResponse as Awaited<ReturnType<Middleware>> | |||||
}; | |||||
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<RequestContext>) => { | |||||
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<RequestContext>) => { | |||||
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<RequestContext>) => (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<string, string> = { | |||||
...( | |||||
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<string, string> = { | |||||
'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<RequestContext>) => (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<string, string> = { | |||||
...( | |||||
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<string, string> = { | |||||
'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<RequestContext>) => { | |||||
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<string, unknown>); | |||||
// 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']>) { | |||||
server.listen(...args); | |||||
return this; | |||||
}, | |||||
close(callback?: (err?: Error) => void) { | |||||
server.close(callback); | |||||
return this; | |||||
}, | |||||
on(...args: Parameters<Server['on']>) { | |||||
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; | |||||
} |
@@ -1,12 +1,12 @@ | |||||
export interface MediaType< | export interface MediaType< | ||||
Name extends string = string, | Name extends string = string, | ||||
T extends object = object, | T extends object = object, | ||||
SerializeOpts extends unknown[] = [], | |||||
DeserializeOpts extends unknown[] = [] | |||||
SerializeOpts extends {} = {}, | |||||
DeserializeOpts extends {} = {} | |||||
> { | > { | ||||
name: Name; | 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 = { | export const FALLBACK_MEDIA_TYPE = { | ||||
@@ -39,6 +39,8 @@ export interface QueryAndGrouping { | |||||
expressions: QueryOrGrouping[]; | expressions: QueryOrGrouping[]; | ||||
} | } | ||||
export type Query = QueryAndGrouping; | |||||
export interface QueryMediaType< | export interface QueryMediaType< | ||||
Name extends string = string, | Name extends string = string, | ||||
SerializeOptions extends {} = {}, | SerializeOptions extends {} = {}, | ||||
@@ -46,6 +48,6 @@ export interface QueryMediaType< | |||||
> extends MediaType< | > extends MediaType< | ||||
Name, | Name, | ||||
QueryAndGrouping, | QueryAndGrouping, | ||||
[SerializeOptions], | |||||
[DeserializeOptions] | |||||
SerializeOptions, | |||||
DeserializeOptions | |||||
> {} | > {} |
@@ -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<Pick<RequestContext, 'resource'>>['resource']; | |||||
interface ResourceWithDataSource extends Omit<RequiredResource, 'dataSource'> { | |||||
dataSource: DataSource; | |||||
} | |||||
interface ResourceRequestContext extends Omit<RequestContext, 'resource'> { | |||||
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<http.Server['listen']>): this; | |||||
defaultErrorHandler(errorHandler: ErrorHandler): this; | |||||
} | |||||
declare module '../../backend' { | |||||
interface RequestContext extends http.IncomingMessage { | |||||
body?: unknown; | |||||
} | |||||
interface Middleware<Req extends ResourceRequestContext = ResourceRequestContext, Res extends NodeJS.EventEmitter = NodeJS.EventEmitter> { | |||||
(req: Req, res: Res): undefined | Response | Promise<undefined | Response>; | |||||
} | |||||
interface Backend { | |||||
createServer<T extends Server = HttpServer>(type: 'http', options?: CreateServerParams): T; | |||||
} | |||||
} | |||||
const constructPostSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => { | |||||
return resource.schema; | |||||
}; | |||||
const constructPutSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>, mainResourceId?: string) => { | |||||
if (typeof mainResourceId === 'undefined') { | |||||
return resource.schema; | |||||
} | |||||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : 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<any>, | |||||
v.object({ | |||||
[idAttr]: v.transform( | |||||
v.any(), | |||||
input => idConfig!.serialize(input), | |||||
v.literal(mainResourceId) | |||||
) | |||||
}) | |||||
]) | |||||
: schema | |||||
); | |||||
}; | |||||
const constructPatchSchema = <T extends v.BaseSchema>(resource: Resource<BaseResourceType & { schema: T }>) => { | |||||
const schema = resource.schema.type === 'object' ? resource.schema as unknown as v.ObjectSchema<any> : resource.schema; | |||||
if (resource.schema.type !== 'object') { | |||||
return resource.schema; | |||||
} | |||||
const schemaChoices = { | |||||
merge: v.partial( | |||||
schema as v.ObjectSchema<any>, | |||||
(schema as v.ObjectSchema<any>).rest, | |||||
(schema as v.ObjectSchema<any>).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<RequestContext>) => <E extends Error = Error>(err?: E) => never; | |||||
interface ServerState { | |||||
requestDecorators: Set<RequestDecorator>; | |||||
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<RequestDecorator>(), | |||||
defaultErrorHandler: undefined, | |||||
}; | |||||
const theServer = { | |||||
...theServerRaw, | |||||
get listening() { return server.listening }, | |||||
listen(...args: Parameters<HttpServer['listen']>) { | |||||
server.listen(...args); | |||||
return this; | |||||
}, | |||||
close(callback?: (err?: Error) => void) { | |||||
server.close(callback); | |||||
return this; | |||||
}, | |||||
on(...args: Parameters<HttpServer['on']>) { | |||||
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<ReturnType<Middleware>>, 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<ReturnType<Middleware>>( | |||||
async (currentHandlerStatePromise, currentMiddleware) => { | |||||
const currentHandlerState = await currentHandlerStatePromise; | |||||
return await handleMiddlewares(currentHandlerState, currentMiddleware, req); | |||||
}, | |||||
Promise.resolve<ReturnType<Middleware>>(undefined) | |||||
) as Awaited<ReturnType<Middleware>>; | |||||
if (typeof middlewareResponse === 'undefined') { | |||||
throw new ErrorPlainResponse('resourceNotFound', { | |||||
statusCode: constants.HTTP_STATUS_NOT_FOUND, | |||||
res: theRes, | |||||
}); | |||||
} | |||||
return middlewareResponse as Awaited<ReturnType<Middleware>> | |||||
}; | |||||
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<RequestContext>) => { | |||||
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<RequestContext>) => { | |||||
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<RequestContext>) => (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<string, string> = { | |||||
...( | |||||
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<string, string> = { | |||||
'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<RequestContext>) => (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<string, string> = { | |||||
...( | |||||
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<string, string> = { | |||||
'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<RequestContext>) => { | |||||
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; | |||||
}; |
@@ -1,8 +1,8 @@ | |||||
import {ContentNegotiation} from '../../../../../common'; | |||||
import {RequestDecorator} from '../../../../common'; | |||||
import {ContentNegotiation} from '../../../../common'; | |||||
import {RequestDecorator} from '../../../../backend'; | |||||
import Negotiator from 'negotiator'; | import Negotiator from 'negotiator'; | ||||
declare module '../../../../common' { | |||||
declare module '../../../../backend' { | |||||
interface RequestContext { | interface RequestContext { | ||||
cn: Partial<ContentNegotiation>; | cn: Partial<ContentNegotiation>; | ||||
} | } |
@@ -1,8 +1,8 @@ | |||||
import {BackendState, ParamRequestDecorator} from '../../../../common'; | |||||
import {BackendState, ParamRequestDecorator} from '../../../../backend'; | |||||
import {decorateRequestWithContentNegotiation} from './content-negotiation'; | import {decorateRequestWithContentNegotiation} from './content-negotiation'; | ||||
import {decorateRequestWithResource} from './resource'; | import {decorateRequestWithResource} from './resource'; | ||||
declare module '../../../../common' { | |||||
declare module '../../../../backend' { | |||||
interface RequestContext { | interface RequestContext { | ||||
backend: BackendState; | backend: BackendState; | ||||
} | } |
@@ -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 { | interface RequestContext { | ||||
resource?: Resource; | resource?: Resource; | ||||
resourceId?: string; | resourceId?: string; |
@@ -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_HEADER_NAME = 'x-original-method' as const; | ||||
const METHOD_SPOOF_ORIGINAL_METHOD = 'POST' as const; | const METHOD_SPOOF_ORIGINAL_METHOD = 'POST' as const; |
@@ -1,6 +1,6 @@ | |||||
import {ParamRequestDecorator} from '../../../../common'; | |||||
import {ParamRequestDecorator} from '../../../../backend'; | |||||
declare module '../../../../common' { | |||||
declare module '../../../../backend' { | |||||
interface RequestContext { | interface RequestContext { | ||||
basePath: string; | basePath: string; | ||||
} | } |
@@ -1,6 +1,6 @@ | |||||
import {ParamRequestDecorator} from '../../../../common'; | |||||
import {ParamRequestDecorator} from '../../../../backend'; | |||||
declare module '../../../../common' { | |||||
declare module '../../../../backend' { | |||||
interface RequestContext { | interface RequestContext { | ||||
host: string; | host: string; | ||||
} | } |
@@ -1,10 +1,10 @@ | |||||
import {ParamRequestDecorator} from '../../../../common'; | |||||
import {ParamRequestDecorator} from '../../../../backend'; | |||||
import {CreateServerParams} from '../../core'; | import {CreateServerParams} from '../../core'; | ||||
import {decorateRequestWithScheme} from './scheme'; | import {decorateRequestWithScheme} from './scheme'; | ||||
import {decorateRequestWithHost} from './host'; | import {decorateRequestWithHost} from './host'; | ||||
import {decorateRequestWithBasePath} from './base-path'; | import {decorateRequestWithBasePath} from './base-path'; | ||||
declare module '../../../../common' { | |||||
declare module '../../../../backend' { | |||||
interface RequestContext { | interface RequestContext { | ||||
rawUrl?: string; | rawUrl?: string; | ||||
query: URLSearchParams; | query: URLSearchParams; |
@@ -1,6 +1,6 @@ | |||||
import {ParamRequestDecorator} from '../../../../common'; | |||||
import {ParamRequestDecorator} from '../../../../backend'; | |||||
declare module '../../../../common' { | |||||
declare module '../../../../backend' { | |||||
interface RequestContext { | interface RequestContext { | ||||
scheme: string; | scheme: string; | ||||
} | } |
@@ -1,8 +1,8 @@ | |||||
import {constants} from 'http2'; | import {constants} from 'http2'; | ||||
import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '../../../common'; | |||||
import {AllowedMiddlewareSpecification, getAllowString, Middleware} from '../../../backend'; | |||||
import {LinkMap} from '../utils'; | import {LinkMap} from '../utils'; | ||||
import {PlainResponse, ErrorPlainResponse} from '../response'; | import {PlainResponse, ErrorPlainResponse} from '../response'; | ||||
import {getAcceptPatchString, getAcceptPostString} from '../../../../common'; | |||||
import {getAcceptPatchString, getAcceptPostString} from '../../../common'; | |||||
export const handleGetRoot: Middleware = (req, res) => { | export const handleGetRoot: Middleware = (req, res) => { | ||||
const { backend, basePath } = req; | const { backend, basePath } = req; |
@@ -1,15 +1,15 @@ | |||||
import { constants } from 'http2'; | import { constants } from 'http2'; | ||||
import * as v from 'valibot'; | import * as v from 'valibot'; | ||||
import {Middleware} from '../../../common'; | |||||
import {ErrorPlainResponse, PlainResponse} from '../response'; | |||||
import assert from 'assert'; | import assert from 'assert'; | ||||
import {Middleware} from '../../../backend'; | |||||
import { | import { | ||||
applyDelta, DataSourceQuery, | |||||
applyDelta, Query, | |||||
Delta, | Delta, | ||||
PATCH_CONTENT_MAP_TYPE, | PATCH_CONTENT_MAP_TYPE, | ||||
PatchContentType, | PatchContentType, | ||||
queryMediaTypes, | queryMediaTypes, | ||||
} from '../../../../common'; | |||||
} from '../../../common'; | |||||
import {ErrorPlainResponse, PlainResponse} from '../response'; | |||||
// TODO add handleQueryCollection() | // TODO add handleQueryCollection() | ||||
@@ -24,7 +24,7 @@ export const handleQueryCollection: Middleware = async (req, res) => { | |||||
let totalItemCount: number | undefined; | let totalItemCount: number | undefined; | ||||
try { | try { | ||||
// check which attributes have specifics on the queries (e.g. fuzzy search on strings) | // 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 | data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource | ||||
if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | if (backend.showTotalItemCountOnGetCollection && typeof resource.dataSource.getTotalCount === 'function') { | ||||
totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); | totalItemCount = await resource.dataSource.getTotalCount(dataSourceQuery); | ||||
@@ -67,7 +67,7 @@ export const handleGetCollection: Middleware = async (req, res) => { | |||||
try { | try { | ||||
// check which attributes have specifics on the queries (e.g. fuzzy search on strings) | // check which attributes have specifics on the queries (e.g. fuzzy search on strings) | ||||
const dataSourceQuery = queryMediaTypes.applicationXWwwFormUrlencoded.deserialize( | 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) | // 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 | data = await resource.dataSource.getMultiple(dataSourceQuery); // TODO paginated responses per resource |
@@ -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<T = unknown, U extends NodeJS.EventEmitter = NodeJS.EventEmitter> extends Response { | interface PlainResponseParams<T = unknown, U extends NodeJS.EventEmitter = NodeJS.EventEmitter> extends Response { | ||||
body?: T; | body?: T; |
@@ -1,5 +1,5 @@ | |||||
import {IncomingMessage} from 'http'; | import {IncomingMessage} from 'http'; | ||||
import {PATCH_CONTENT_TYPES} from '../../../common'; | |||||
import {PATCH_CONTENT_TYPES} from '../../common'; | |||||
export const isTextMediaType = (mediaType: string) => ( | export const isTextMediaType = (mediaType: string) => ( | ||||
mediaType.startsWith('text/') | mediaType.startsWith('text/') |
@@ -1,7 +1,8 @@ | |||||
import {describe, afterAll, beforeAll, it} from 'vitest'; | import {describe, afterAll, beforeAll, it} from 'vitest'; | ||||
import {Application, application, resource, Resource, validation as v} from '../../src/common'; | 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 {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../utils'; | ||||
import {httpExtender, HttpServer} from '../../src/servers/http'; | |||||
const PORT = 3001; | const PORT = 3001; | ||||
const HOST = '127.0.0.1'; | const HOST = '127.0.0.1'; | ||||
@@ -17,7 +18,7 @@ describe('decorators', () => { | |||||
let app: Application; | let app: Application; | ||||
let dataSource: DataSource; | let dataSource: DataSource; | ||||
let backend: Backend; | let backend: Backend; | ||||
let server: ReturnType<Backend['createHttpServer']>; | |||||
let server: HttpServer; | |||||
let client: TestClient; | let client: TestClient; | ||||
beforeAll(() => { | beforeAll(() => { | ||||
@@ -44,11 +45,13 @@ describe('decorators', () => { | |||||
dataSource = new DummyDataSource(); | 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 | basePath: BASE_PATH | ||||
}); | }); | ||||
@@ -18,6 +18,7 @@ import { | |||||
Application, | Application, | ||||
} from '../../../src/common'; | } from '../../../src/common'; | ||||
import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../../utils'; | import {createTestClient, DummyDataSource, dummyGenerationStrategy, TEST_LANGUAGE, TestClient} from '../../utils'; | ||||
import {httpExtender, HttpServer} from '../../../src/servers/http'; | |||||
const PORT = 3000; | const PORT = 3000; | ||||
const HOST = '127.0.0.1'; | const HOST = '127.0.0.1'; | ||||
@@ -35,7 +36,7 @@ describe('happy path', () => { | |||||
let app: Application; | let app: Application; | ||||
let dataSource: DataSource; | let dataSource: DataSource; | ||||
let backend: Backend; | let backend: Backend; | ||||
let server: ReturnType<Backend['createHttpServer']>; | |||||
let server: HttpServer; | |||||
let client: TestClient; | let client: TestClient; | ||||
beforeAll(() => { | beforeAll(() => { | ||||
@@ -62,11 +63,13 @@ describe('happy path', () => { | |||||
dataSource = new DummyDataSource(); | dataSource = new DummyDataSource(); | ||||
backend = app.createBackend({ | |||||
dataSource, | |||||
}); | |||||
backend = app | |||||
.createBackend({ | |||||
dataSource, | |||||
}) | |||||
.use(httpExtender); | |||||
server = backend.createHttpServer({ | |||||
server = backend.createServer('http', { | |||||
basePath: BASE_PATH | basePath: BASE_PATH | ||||
}); | }); | ||||
@@ -5,7 +5,8 @@ import { | |||||
beforeEach, | beforeEach, | ||||
describe, | describe, | ||||
expect, | expect, | ||||
it, vi, | |||||
it, | |||||
vi, | |||||
} from 'vitest'; | } from 'vitest'; | ||||
import {constants} from 'http2'; | import {constants} from 'http2'; | ||||
import {Backend, DataSource} from '../../../src/backend'; | import {Backend, DataSource} from '../../../src/backend'; | ||||
@@ -18,6 +19,7 @@ import { | |||||
TEST_LANGUAGE, | TEST_LANGUAGE, | ||||
dummyGenerationStrategy, | dummyGenerationStrategy, | ||||
} from '../../utils'; | } from '../../utils'; | ||||
import {httpExtender, HttpServer} from '../../../src/servers/http'; | |||||
const PORT = 3001; | const PORT = 3001; | ||||
const HOST = '127.0.0.1'; | const HOST = '127.0.0.1'; | ||||
@@ -35,7 +37,7 @@ describe('error handling', () => { | |||||
let app: Application; | let app: Application; | ||||
let dataSource: DataSource; | let dataSource: DataSource; | ||||
let backend: Backend; | let backend: Backend; | ||||
let server: ReturnType<Backend['createHttpServer']>; | |||||
let server: HttpServer; | |||||
let client: TestClient; | let client: TestClient; | ||||
beforeAll(() => { | beforeAll(() => { | ||||
@@ -62,11 +64,13 @@ describe('error handling', () => { | |||||
dataSource = new DummyDataSource(); | dataSource = new DummyDataSource(); | ||||
backend = app.createBackend({ | |||||
dataSource, | |||||
}); | |||||
backend = app | |||||
.createBackend({ | |||||
dataSource, | |||||
}) | |||||
.use(httpExtender); | |||||
server = backend.createHttpServer({ | |||||
server = backend.createServer('http', { | |||||
basePath: BASE_PATH | basePath: BASE_PATH | ||||
}); | }); | ||||