Server lives on its own domain, with the default server being HTTP.master
@@ -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<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[]) => { | |||
const allowedMethods = middlewares.flatMap((m) => m.method === 'GET' ? [m.method, 'HEAD'] : [m.method]); | |||
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'; | |||
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> { | |||
@@ -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; | |||
}; |
@@ -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'; |
@@ -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< | |||
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 = { | |||
@@ -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 | |||
> {} |
@@ -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'; | |||
declare module '../../../../common' { | |||
declare module '../../../../backend' { | |||
interface RequestContext { | |||
cn: Partial<ContentNegotiation>; | |||
} |
@@ -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; | |||
} |
@@ -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; |
@@ -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; |
@@ -1,6 +1,6 @@ | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
import {ParamRequestDecorator} from '../../../../backend'; | |||
declare module '../../../../common' { | |||
declare module '../../../../backend' { | |||
interface RequestContext { | |||
basePath: string; | |||
} |
@@ -1,6 +1,6 @@ | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
import {ParamRequestDecorator} from '../../../../backend'; | |||
declare module '../../../../common' { | |||
declare module '../../../../backend' { | |||
interface RequestContext { | |||
host: string; | |||
} |
@@ -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; |
@@ -1,6 +1,6 @@ | |||
import {ParamRequestDecorator} from '../../../../common'; | |||
import {ParamRequestDecorator} from '../../../../backend'; | |||
declare module '../../../../common' { | |||
declare module '../../../../backend' { | |||
interface RequestContext { | |||
scheme: string; | |||
} |
@@ -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; |
@@ -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 |
@@ -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 { | |||
body?: T; |
@@ -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/') |
@@ -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<Backend['createHttpServer']>; | |||
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 | |||
}); | |||
@@ -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<Backend['createHttpServer']>; | |||
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 | |||
}); | |||
@@ -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<Backend['createHttpServer']>; | |||
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 | |||
}); | |||