Browse Source

Extract server from backend

Server lives on its own domain, with the default server being HTTP.
master
TheoryOfNekomata 7 months ago
parent
commit
2484e62171
23 changed files with 918 additions and 925 deletions
  1. +14
    -0
      packages/core/src/backend/common.ts
  2. +13
    -12
      packages/core/src/backend/core.ts
  3. +0
    -3
      packages/core/src/backend/index.ts
  4. +0
    -859
      packages/core/src/backend/servers/http/core.ts
  5. +4
    -4
      packages/core/src/common/media-type.ts
  6. +4
    -2
      packages/core/src/common/queries/common.ts
  7. +828
    -0
      packages/core/src/servers/http/core.ts
  8. +3
    -3
      packages/core/src/servers/http/decorators/backend/content-negotiation.ts
  9. +2
    -2
      packages/core/src/servers/http/decorators/backend/index.ts
  10. +3
    -3
      packages/core/src/servers/http/decorators/backend/resource.ts
  11. +1
    -1
      packages/core/src/servers/http/decorators/method/index.ts
  12. +2
    -2
      packages/core/src/servers/http/decorators/url/base-path.ts
  13. +2
    -2
      packages/core/src/servers/http/decorators/url/host.ts
  14. +2
    -2
      packages/core/src/servers/http/decorators/url/index.ts
  15. +2
    -2
      packages/core/src/servers/http/decorators/url/scheme.ts
  16. +2
    -2
      packages/core/src/servers/http/handlers/default.ts
  17. +6
    -6
      packages/core/src/servers/http/handlers/resource.ts
  18. +0
    -0
      packages/core/src/servers/http/index.ts
  19. +2
    -2
      packages/core/src/servers/http/response.ts
  20. +1
    -1
      packages/core/src/servers/http/utils.ts
  21. +9
    -6
      packages/core/test/features/decorators.test.ts
  22. +8
    -5
      packages/core/test/handlers/http/default.test.ts
  23. +10
    -6
      packages/core/test/handlers/http/error-handling.test.ts

+ 14
- 0
packages/core/src/backend/common.ts View File

@@ -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(',');


+ 13
- 12
packages/core/src/backend/core.ts View File

@@ -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;
};

+ 0
- 3
packages/core/src/backend/index.ts View File

@@ -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';

+ 0
- 859
packages/core/src/backend/servers/http/core.ts View File

@@ -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;
}

+ 4
- 4
packages/core/src/common/media-type.ts View File

@@ -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 = {


+ 4
- 2
packages/core/src/common/queries/common.ts View File

@@ -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
> {}

+ 828
- 0
packages/core/src/servers/http/core.ts View File

@@ -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;
};

packages/core/src/backend/servers/http/decorators/backend/content-negotiation.ts → packages/core/src/servers/http/decorators/backend/content-negotiation.ts View File

@@ -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>;
}

packages/core/src/backend/servers/http/decorators/backend/index.ts → packages/core/src/servers/http/decorators/backend/index.ts View File

@@ -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;
}

packages/core/src/backend/servers/http/decorators/backend/resource.ts → packages/core/src/servers/http/decorators/backend/resource.ts View File

@@ -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;

packages/core/src/backend/servers/http/decorators/method/index.ts → packages/core/src/servers/http/decorators/method/index.ts View File

@@ -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;

packages/core/src/backend/servers/http/decorators/url/base-path.ts → packages/core/src/servers/http/decorators/url/base-path.ts View File

@@ -1,6 +1,6 @@
import {ParamRequestDecorator} from '../../../../common';
import {ParamRequestDecorator} from '../../../../backend';

declare module '../../../../common' {
declare module '../../../../backend' {
interface RequestContext {
basePath: string;
}

packages/core/src/backend/servers/http/decorators/url/host.ts → packages/core/src/servers/http/decorators/url/host.ts View File

@@ -1,6 +1,6 @@
import {ParamRequestDecorator} from '../../../../common';
import {ParamRequestDecorator} from '../../../../backend';

declare module '../../../../common' {
declare module '../../../../backend' {
interface RequestContext {
host: string;
}

packages/core/src/backend/servers/http/decorators/url/index.ts → packages/core/src/servers/http/decorators/url/index.ts View File

@@ -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;

packages/core/src/backend/servers/http/decorators/url/scheme.ts → packages/core/src/servers/http/decorators/url/scheme.ts View File

@@ -1,6 +1,6 @@
import {ParamRequestDecorator} from '../../../../common';
import {ParamRequestDecorator} from '../../../../backend';

declare module '../../../../common' {
declare module '../../../../backend' {
interface RequestContext {
scheme: string;
}

packages/core/src/backend/servers/http/handlers/default.ts → packages/core/src/servers/http/handlers/default.ts View File

@@ -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;

packages/core/src/backend/servers/http/handlers/resource.ts → packages/core/src/servers/http/handlers/resource.ts View File

@@ -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

packages/core/src/backend/servers/http/index.ts → packages/core/src/servers/http/index.ts View File


packages/core/src/backend/servers/http/response.ts → packages/core/src/servers/http/response.ts View File

@@ -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;

packages/core/src/backend/servers/http/utils.ts → packages/core/src/servers/http/utils.ts View File

@@ -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/')

+ 9
- 6
packages/core/test/features/decorators.test.ts View File

@@ -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
});



+ 8
- 5
packages/core/test/handlers/http/default.test.ts View File

@@ -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
});



+ 10
- 6
packages/core/test/handlers/http/error-handling.test.ts View File

@@ -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
});



Loading…
Cancel
Save