From 67b097a026d88bc073d8d583837b94970c734ada Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 27 Mar 2024 18:40:35 +0800 Subject: [PATCH] Add decorator system Implement decorator system with requests. --- examples/basic/server.ts | 2 +- src/app.ts | 67 ------- src/backend/common.ts | 17 +- src/backend/core.ts | 10 +- .../decorators/backend/content-negotiation.ts | 29 +++ src/backend/decorators/backend/index.ts | 18 ++ src/backend/decorators/backend/resource.ts | 25 +++ src/backend/decorators/method/index.ts | 7 + src/backend/decorators/url/base-path.ts | 13 ++ src/backend/decorators/url/host.ts | 13 ++ src/backend/decorators/url/index.ts | 26 +++ src/backend/decorators/url/scheme.ts | 13 ++ src/backend/handlers.ts | 84 +++++++-- src/backend/server.ts | 165 ++++-------------- src/backend/utils.ts | 62 ++++--- src/client/index.ts | 18 +- src/common/app.ts | 68 +++++++- src/common/charset.ts | 6 + src/common/index.ts | 57 +----- src/common/language.ts | 41 +++++ src/common/media-type.ts | 6 + src/common/resource.ts | 4 + src/common/validation.ts | 5 - src/index.ts | 2 - test/e2e/default.test.ts | 14 +- 25 files changed, 442 insertions(+), 330 deletions(-) delete mode 100644 src/app.ts create mode 100644 src/backend/decorators/backend/content-negotiation.ts create mode 100644 src/backend/decorators/backend/index.ts create mode 100644 src/backend/decorators/backend/resource.ts create mode 100644 src/backend/decorators/method/index.ts create mode 100644 src/backend/decorators/url/base-path.ts create mode 100644 src/backend/decorators/url/host.ts create mode 100644 src/backend/decorators/url/index.ts create mode 100644 src/backend/decorators/url/scheme.ts diff --git a/examples/basic/server.ts b/examples/basic/server.ts index 95e1581..1ba6361 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -58,7 +58,7 @@ const backend = app.createBackend({ dataSource, }); -const server = backend.createServer({ +const server = backend.createHttpServer({ basePath: '/api' }); diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index e56f55d..0000000 --- a/src/app.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as v from 'valibot'; -import { - Resource, - Language, - MediaType, - Charset, - ApplicationParams, - ApplicationState, - FALLBACK_LANGUAGE, - FALLBACK_CHARSET, FALLBACK_MEDIA_TYPE, -} from './common'; -import {BackendBuilder, createBackend, CreateBackendParams} from './backend'; -import {ClientBuilder, createClient, CreateClientParams} from './client'; - -export interface ApplicationBuilder { - mediaType(mediaType: MediaType): this; - language(language: Language): this; - charset(charset: Charset): this; - resource(resRaw: Resource): this; - createBackend(params: Omit): BackendBuilder; - createClient(params: Omit): ClientBuilder; -} - -export const application = (appParams: ApplicationParams): ApplicationBuilder => { - const appState: ApplicationState = { - name: appParams.name, - resources: new Set>(), - languages: new Set(), - mediaTypes: new Set(), - charsets: new Set(), - }; - - appState.languages.add(FALLBACK_LANGUAGE); - appState.charsets.add(FALLBACK_CHARSET); - appState.mediaTypes.add(FALLBACK_MEDIA_TYPE); - - return { - mediaType(serializerPair: MediaType) { - appState.mediaTypes.add(serializerPair); - return this; - }, - charset(encodingPair: Charset) { - appState.charsets.add(encodingPair); - return this; - }, - language(language: Language) { - appState.languages.add(language); - return this; - }, - resource(resRaw: Resource) { - appState.resources.add(resRaw); - return this; - }, - createBackend(params: Omit) { - return createBackend({ - ...params, - app: appState - }); - }, - createClient(params: Omit) { - return createClient({ - ...params, - app: appState - }); - }, - }; -}; diff --git a/src/backend/common.ts b/src/backend/common.ts index 6e30be4..367db64 100644 --- a/src/backend/common.ts +++ b/src/backend/common.ts @@ -1,16 +1,21 @@ -import {ApplicationState, Charset, Language, MediaType, Resource} from '../common'; +import {ApplicationState, ContentNegotiation, Resource} from '../common'; import {BaseDataSource} from '../common/data-source'; +import http from 'http'; export interface BackendState { app: ApplicationState; dataSource: (resource: Resource) => BaseDataSource; - cn: { - language: Language; - charset: Charset; - mediaType: MediaType; - } + cn: ContentNegotiation; showTotalItemCountOnGetCollection: boolean; throws404OnDeletingNotFound: boolean; checksSerializersOnDelete: boolean; showTotalItemCountOnCreateItem: boolean; } + +export interface RequestContext extends http.IncomingMessage { + body?: unknown; +} + +export type RequestDecorator = (req: RequestContext) => RequestContext | Promise; + +export type ParamRequestDecorator = []> = (...args: Params) => RequestDecorator; diff --git a/src/backend/core.ts b/src/backend/core.ts index 55771bb..053c25c 100644 --- a/src/backend/core.ts +++ b/src/backend/core.ts @@ -23,8 +23,8 @@ export interface BackendBuilder { showTotalItemCountOnGetCollection(b?: boolean): this; showTotalItemCountOnCreateItem(b?: boolean): this; checksSerializersOnDelete(b?: boolean): this; - throws404OnDeletingNotFound(b?: boolean): this; - createServer(serverParams?: CreateServerParams): http.Server | https.Server; + throwsErrorOnDeletingNotFound(b?: boolean): this; + createHttpServer(serverParams?: CreateServerParams): http.Server | https.Server; dataSource?: (resource: Resource) => T; } @@ -57,7 +57,7 @@ export const createBackend = (params: CreateBackendParams) => { backendState.showTotalItemCountOnCreateItem = b; return this; }, - throws404OnDeletingNotFound(b = true) { + throwsErrorOnDeletingNotFound(b = true) { backendState.throws404OnDeletingNotFound = b; return this; }, @@ -65,8 +65,8 @@ export const createBackend = (params: CreateBackendParams) => { backendState.checksSerializersOnDelete = b; return this; }, - createServer(serverParams = {} as CreateServerParams) { + createHttpServer(serverParams = {} as CreateServerParams) { return createServer(backendState, serverParams); - } + }, } satisfies BackendBuilder; }; diff --git a/src/backend/decorators/backend/content-negotiation.ts b/src/backend/decorators/backend/content-negotiation.ts new file mode 100644 index 0000000..7ba8f42 --- /dev/null +++ b/src/backend/decorators/backend/content-negotiation.ts @@ -0,0 +1,29 @@ +import {ContentNegotiation} from '../../../common'; +import {RequestDecorator} from '../../common'; +import Negotiator from 'negotiator'; + +declare module '../../common' { + interface RequestContext { + cn: ContentNegotiation; + } +} + +export const decorateRequestWithContentNegotiation: RequestDecorator = (req) => { + const negotiator = new Negotiator(req); + + const availableLanguages = Array.from(req.backend.app.languages.values() ?? []); + const availableCharsets = Array.from(req.backend.app.charsets.values() ?? []); + const availableMediaTypes = Array.from(req.backend.app.mediaTypes.values() ?? []); + + const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? req.backend.cn.language.name; + const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? req.backend.cn.charset.name; + const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? req.backend.cn.mediaType.name; + + req.cn = { + language: req.backend.app.languages.get(languageCandidate) ?? req.backend.cn.language, + mediaType: req.backend.app.mediaTypes.get(mediaTypeCandidate) ?? req.backend.cn.mediaType, + charset: req.backend.app.charsets.get(charsetCandidate) ?? req.backend.cn.charset, + }; + + return req; +}; diff --git a/src/backend/decorators/backend/index.ts b/src/backend/decorators/backend/index.ts new file mode 100644 index 0000000..b8d52ee --- /dev/null +++ b/src/backend/decorators/backend/index.ts @@ -0,0 +1,18 @@ +import {BackendState, ParamRequestDecorator} from '../../common'; +import {decorateRequestWithContentNegotiation} from './content-negotiation'; +import {decorateRequestWithResource} from './resource'; + +declare module '../../common' { + interface RequestContext { + backend: BackendState; + } +} + +export const decorateRequestWithBackend: ParamRequestDecorator<[BackendState]> = (backend) => (req) => { + req.backend = backend; + + decorateRequestWithContentNegotiation(req); + decorateRequestWithResource(req); + + return req; +}; diff --git a/src/backend/decorators/backend/resource.ts b/src/backend/decorators/backend/resource.ts new file mode 100644 index 0000000..673bb3d --- /dev/null +++ b/src/backend/decorators/backend/resource.ts @@ -0,0 +1,25 @@ +import {RequestDecorator} from '../../common'; +import {DataSource} from '../../data-source'; +import {Resource} from '../../../common'; +import {BackendResource} from '../../core'; + +declare module '../../common' { + interface RequestContext { + resource?: Resource; + resourceId?: string; + } +} + +export const decorateRequestWithResource: RequestDecorator = (req) => { + const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? []; + const resource = Array.from(req.backend.app.resources) + .find((r) => r.state.routeName === resourceRouteName) as BackendResource | undefined; + + if (typeof resource !== 'undefined') { + req.resource = resource; + req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; + req.resourceId = resourceId; + } + + return req; +}; diff --git a/src/backend/decorators/method/index.ts b/src/backend/decorators/method/index.ts new file mode 100644 index 0000000..412feca --- /dev/null +++ b/src/backend/decorators/method/index.ts @@ -0,0 +1,7 @@ +import {RequestDecorator} from '../../common'; + +export const decorateRequestWithMethod: RequestDecorator = (req) => { + req.method = req.method?.trim().toUpperCase() ?? ''; + + return req; +}; diff --git a/src/backend/decorators/url/base-path.ts b/src/backend/decorators/url/base-path.ts new file mode 100644 index 0000000..17e230c --- /dev/null +++ b/src/backend/decorators/url/base-path.ts @@ -0,0 +1,13 @@ +import {ParamRequestDecorator} from '../../common'; + +declare module '../../common' { + interface RequestContext { + basePath: string; + } +} + +export const decorateRequestWithBasePath: ParamRequestDecorator<[string]> = (basePath) => (req) => { + req.basePath = basePath; + + return req; +} diff --git a/src/backend/decorators/url/host.ts b/src/backend/decorators/url/host.ts new file mode 100644 index 0000000..2cf8143 --- /dev/null +++ b/src/backend/decorators/url/host.ts @@ -0,0 +1,13 @@ +import {ParamRequestDecorator} from '../../common'; + +declare module '../../common' { + interface RequestContext { + host: string; + } +} + +export const decorateRequestWithHost: ParamRequestDecorator<[string]> = (host) => (req) => { + req.host = host; + + return req; +}; diff --git a/src/backend/decorators/url/index.ts b/src/backend/decorators/url/index.ts new file mode 100644 index 0000000..1bfa385 --- /dev/null +++ b/src/backend/decorators/url/index.ts @@ -0,0 +1,26 @@ +import {ParamRequestDecorator} from '../../common'; +import {CreateServerParams} from '../../server'; +import {decorateRequestWithScheme} from './scheme'; +import {decorateRequestWithHost} from './host'; +import {decorateRequestWithBasePath} from './base-path'; + +declare module '../../common' { + interface RequestContext { + rawUrl?: string; + query: URLSearchParams; + } +} + +export const decorateRequestWithUrl: ParamRequestDecorator<[CreateServerParams]> = (serverParams) => (req) => { + const isHttps = 'key' in serverParams && 'cert' in serverParams; + decorateRequestWithScheme(isHttps ? 'https' : 'http')(req); + decorateRequestWithHost(serverParams.host ?? '127.0.0.1')(req); + decorateRequestWithBasePath(serverParams.basePath ?? '')(req); + const basePath = new URL(req.basePath, `${req.scheme}://${req.host}`); + const parsedUrl = new URL(`${basePath.pathname}/${req.url ?? ''}`, basePath.origin); + req.rawUrl = req.url; + req.url = req.url?.slice(basePath.pathname.length) ?? ''; + req.query = parsedUrl.searchParams; + + return req; +}; diff --git a/src/backend/decorators/url/scheme.ts b/src/backend/decorators/url/scheme.ts new file mode 100644 index 0000000..e877e2d --- /dev/null +++ b/src/backend/decorators/url/scheme.ts @@ -0,0 +1,13 @@ +import {ParamRequestDecorator} from '../../common'; + +declare module '../../common' { + interface RequestContext { + scheme: string; + } +} + +export const decorateRequestWithScheme: ParamRequestDecorator<[string]> = (scheme) => (req) => { + req.scheme = scheme; + + return req; +}; diff --git a/src/backend/handlers.ts b/src/backend/handlers.ts index 149e087..184f844 100644 --- a/src/backend/handlers.ts +++ b/src/backend/handlers.ts @@ -1,15 +1,17 @@ import { constants } from 'http2'; import * as v from 'valibot'; import {HttpMiddlewareError, PlainResponse, Middleware} from './server'; +import {LinkMap} from './utils'; +import {BackendResource} from './core'; export const handleGetRoot: Middleware = (req) => { const { backend, basePath } = req; const data = { - name: backend!.app.name + name: backend.app.name }; - const registeredResources = Array.from(backend!.app.resources); + const registeredResources = Array.from(backend.app.resources); const availableResources = registeredResources.filter((r) => ( r.state.canFetchCollection || r.state.canCreate @@ -18,13 +20,16 @@ export const handleGetRoot: Middleware = (req) => { const headers: Record = {}; if (availableResources.length > 0) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link - headers['Link'] = availableResources - .map((r) => [ - `<${basePath}/${r.state.routeName}>`, - 'rel="related"', - `name="${encodeURIComponent(r.state.routeName)}"` - ].join('; ')) - .join(', '); + headers['Link'] = new LinkMap( + availableResources.map((r) => ({ + url: `${basePath}/${r.state.routeName}`, + params: { + rel: 'related', + name: r.state.routeName, + }, + })) + ) + .toString(); } return new PlainResponse({ @@ -36,7 +41,12 @@ export const handleGetRoot: Middleware = (req) => { }; export const handleGetCollection: Middleware = async (req) => { - const { query, resource, backend } = req; + const { query, resource: resourceRaw, backend } = req; + + if (typeof resourceRaw === 'undefined') { + throw new Error('No resource'); + } + const resource = resourceRaw as BackendResource; let data: v.Output[]; let totalItemCount: number | undefined; @@ -57,7 +67,6 @@ export const handleGetCollection: Middleware = async (req) => { } const headers: Record = {}; - if (typeof totalItemCount !== 'undefined') { headers['X-Resource-Total-Item-Count'] = totalItemCount.toString(); } @@ -71,7 +80,12 @@ export const handleGetCollection: Middleware = async (req) => { }; export const handleGetItem: Middleware = async (req) => { - const { resource, resourceId } = req; + const { resource: resourceRaw, resourceId } = req; + + if (typeof resourceRaw === 'undefined') { + throw new Error('No resource'); + } + const resource = resourceRaw as BackendResource; if (typeof resourceId === 'undefined') { throw new HttpMiddlewareError( @@ -82,6 +96,15 @@ export const handleGetItem: Middleware = async (req) => { ); } + if ((resourceId.trim().length ?? 0) < 1) { + throw new HttpMiddlewareError( + 'resourceIdNotGiven', + { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } + let data: v.Output | null = null; try { data = await resource.dataSource.getById(resourceId); @@ -112,7 +135,12 @@ export const handleGetItem: Middleware = async (req) => { }; export const handleDeleteItem: Middleware = async (req) => { - const { resource, resourceId, backend } = req; + const { resource: resourceRaw, resourceId, backend } = req; + + if (typeof resourceRaw === 'undefined') { + throw new Error('No resource'); + } + const resource = resourceRaw as BackendResource; if (typeof resourceId === 'undefined') { throw new HttpMiddlewareError( @@ -157,7 +185,21 @@ export const handleDeleteItem: Middleware = async (req) => { }; export const handlePatchItem: Middleware = async (req) => { - const { resource, resourceId, body } = req; + const { resource: resourceRaw, resourceId, body } = req; + + if (typeof resourceRaw === 'undefined') { + throw new Error('No resource'); + } + const resource = resourceRaw as BackendResource; + + if (typeof resourceId === 'undefined') { + throw new HttpMiddlewareError( + 'resourceIdNotGiven', + { + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } let existing: unknown | null; try { @@ -195,7 +237,12 @@ export const handlePatchItem: Middleware = async (req) => { }; export const handleCreateItem: Middleware = async (req) => { - const { resource, body, backend, basePath } = req; + const { resource: resourceRaw, body, backend, basePath } = req; + + if (typeof resourceRaw === 'undefined') { + throw new Error('No resource'); + } + const resource = resourceRaw as BackendResource; let newId; let params: v.Output; @@ -249,7 +296,12 @@ export const handleCreateItem: Middleware = async (req) => { } export const handleEmplaceItem: Middleware = async (req) => { - const { resource, resourceId, basePath, body, backend } = req; + const { resource: resourceRaw, resourceId, basePath, body, backend } = req; + + if (typeof resourceRaw === 'undefined') { + throw new Error('No resource'); + } + const resource = resourceRaw as BackendResource; let newObject: v.Output; let isCreated: boolean; diff --git a/src/backend/server.ts b/src/backend/server.ts index e7df9b4..24d83eb 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -1,8 +1,7 @@ import http from 'http'; -import {BackendState} from './common'; -import {Language, Resource, Charset, MediaType, LanguageStatusMessageMap} from '../common'; +import {BackendState, RequestContext} from './common'; +import {Language, Resource, LanguageStatusMessageMap} from '../common'; import https from 'https'; -import Negotiator from 'negotiator'; import {constants} from 'http2'; import { handleCreateItem, @@ -18,7 +17,9 @@ import { } from './core'; import * as v from 'valibot'; import {getBody} from './utils'; -import {DataSource} from './data-source'; +import {decorateRequestWithBackend} from './decorators/backend'; +import {decorateRequestWithMethod} from './decorators/method'; +import {decorateRequestWithUrl} from './decorators/url'; export interface Response { statusCode: number; @@ -83,36 +84,6 @@ export interface CreateServerParams { streamResponses?: boolean; } -export interface RequestContext extends http.IncomingMessage { - backend?: BackendState; - - host?: string; - - scheme?: string; - - basePath?: string; - - method?: string; - - url?: string; - - rawUrl?: string; - - cn: { - language: Language; - mediaType: MediaType; - charset: Charset; - }; - - query: URLSearchParams; - - resource: BackendResource; - - resourceId?: string; - - body?: unknown; -} - export interface Middleware { (req: Req): undefined | Response | Promise; } @@ -175,73 +146,6 @@ const getAllowedMiddlewares = (resource?: Resource, m return middlewares; }; -const adjustRequestForContentNegotiation = (req: RequestContext, res: http.ServerResponse) => { - const negotiator = new Negotiator(req); - const availableLanguages = Array.from(req.backend!.app.languages); - const availableCharsets = Array.from(req.backend!.app.charsets); - const availableMediaTypes = Array.from(req.backend!.app.mediaTypes); - - const languageCandidate = negotiator.language(availableLanguages.map((l) => l.name)) ?? req.backend!.cn.language.name; - const charsetCandidate = negotiator.charset(availableCharsets.map((l) => l.name)) ?? req.backend!.cn.charset.name; - const mediaTypeCandidate = negotiator.mediaType(availableMediaTypes.map((l) => l.name)) ?? req.backend!.cn.mediaType.name; - - // TODO refactor - const currentLanguage = availableLanguages.find((l) => l.name === languageCandidate); - if (typeof currentLanguage === 'undefined') { - const data = req.backend?.cn.language.bodies.languageNotAcceptable; - const responseRaw = req.backend?.cn.mediaType.serialize(data); - const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; - res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { - 'Content-Language': req.backend?.cn.language.name, - 'Content-Type': [ - req.backend?.cn.mediaType.name, - `charset="${req.backend?.cn.charset.name}"` - ].join('; '), - }); - res.statusMessage = req.backend?.cn.language.statusMessages.languageNotAcceptable ?? ''; - res.end(response); - return; - } - - const currentMediaType = availableMediaTypes.find((l) => l.name === mediaTypeCandidate); - if (typeof currentMediaType === 'undefined') { - const data = req.backend?.cn.language.bodies.mediaTypeNotAcceptable; - const responseRaw = req.backend?.cn.mediaType.serialize(data); - const response = typeof responseRaw !== 'undefined' ? req.backend?.cn.charset.encode(responseRaw) : undefined; - res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { - 'Content-Language': req.backend?.cn.language.name, - 'Content-Type': [ - req.backend?.cn.mediaType.name, - `charset="${req.backend?.cn.charset.name}"` - ].join('; '), - }); - res.statusMessage = req.backend?.cn.language.statusMessages.mediaTypeNotAcceptable ?? ''; - res.end(response); - return; - } - - const responseBodyCharset = availableCharsets.find((l) => l.name === charsetCandidate); - if (typeof responseBodyCharset === 'undefined') { - const data = req.backend!.cn.language.bodies.encodingNotAcceptable; - const responseRaw = req.backend!.cn.mediaType.serialize(data); - const response = typeof responseRaw !== 'undefined' ? req.backend!.cn.charset.encode(responseRaw) : undefined; - res.writeHead(constants.HTTP_STATUS_NOT_ACCEPTABLE, { - 'Content-Language': req.backend?.cn.language.name, - 'Content-Type': [ - req.backend?.cn.mediaType.name, - `charset="${req.backend?.cn.charset.name}"` - ].join('; '), - }); - res.statusMessage = req.backend?.cn.language.statusMessages.encodingNotAcceptable ?? ''; - res.end(response); - return; - } - - req.cn.language = currentLanguage; - req.cn.mediaType = currentMediaType; - req.cn.charset = responseBodyCharset; -}; - export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { const isHttps = 'key' in serverParams && 'cert' in serverParams; @@ -255,31 +159,32 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr requestTimeout: serverParams.requestTimeout, }); - server.on('request', async (req: RequestContext, res: http.ServerResponse) => { - req.backend = backendState; - req.basePath = serverParams.basePath ?? ''; - req.host = serverParams.host ?? 'localhost'; - req.scheme = isHttps ? 'https' : 'http'; - req.cn = req.backend.cn; - req.method = req.method?.trim().toUpperCase() ?? ''; - - const theBasePathUrl = req.basePath ?? ''; - const basePath = new URL(theBasePathUrl, 'http://localhost'); - const parsedUrl = new URL(`${theBasePathUrl}/${req.url ?? ''}`, 'http://localhost'); - req.rawUrl = req.url; - req.url = req.url?.slice(basePath.pathname.length) ?? ''; - req.query = parsedUrl.searchParams; - - adjustRequestForContentNegotiation(req, res); + const requestDecorators = [ + decorateRequestWithMethod, + decorateRequestWithUrl(serverParams), + decorateRequestWithBackend(backendState), + ]; + + const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { + let req: RequestContext; + // TODO custom decorators + const effectiveRequestDecorators = requestDecorators; + req = await effectiveRequestDecorators.reduce( + async (resultRequestPromise, decorator) => { + const resultRequest = await resultRequestPromise; + + return await decorator(resultRequest); + }, + Promise.resolve(reqRaw) + ); let middlewareState; if (req.url === '/' || req.url === '') { middlewareState = await handleGetRoot(req); } + let resource = req.resource as BackendResource | undefined; if (typeof middlewareState === 'undefined') { - const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? []; - const resource = Array.from(req.backend.app.resources).find((r) => r.state!.routeName === resourceRouteName); if (typeof resource === 'undefined') { res.statusCode = constants.HTTP_STATUS_NOT_FOUND; res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound; @@ -287,12 +192,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr return; } - req.resource = resource as BackendResource; - req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; - req.resourceId = resourceId; - try { - await req.resource.dataSource.initialize(); + await resource.dataSource.initialize(); } catch (cause) { throw new HttpMiddlewareError( 'unableToInitializeResourceDataSource', @@ -321,8 +222,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } if (schema) { - const availableSerializers = Array.from(req.backend!.app.mediaTypes); - const availableCharsets = Array.from(req.backend!.app.charsets); + const availableSerializers = Array.from(req.backend!.app.mediaTypes.values()); + const availableCharsets = Array.from(req.backend!.app.charsets.values()); const contentTypeHeader = req.headers['content-type'] ?? 'application/octet-stream'; const fragments = contentTypeHeader.split(';'); const mediaType = fragments[0]; @@ -424,7 +325,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } const statusMessageKey = middlewareState.statusMessage ? req.cn.language.statusMessages[middlewareState.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; res.writeHead(middlewareState.statusCode, headers); if (typeof encoded !== 'undefined') { res.end(encoded); @@ -459,7 +360,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ].join('; '); const statusMessageKey = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; res.writeHead(finalErr.response.statusCode, headers); if (typeof encoded !== 'undefined') { res.end(encoded); @@ -471,7 +372,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } if (middlewares.length > 0) { - res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; + res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { Allow: middlewares.map((m) => m[0]).join(', '), 'Content-Language': req.backend.cn.language.name, @@ -481,13 +382,15 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } // TODO error handler in line with authentication - res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, req.resource.state.itemName) ?? ''; + res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { 'Content-Language': req.backend.cn.language.name, }); res.end(); return; - }); + }; + + server.on('request', handleRequest); return server; } diff --git a/src/backend/utils.ts b/src/backend/utils.ts index c8036a2..e9e733b 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -3,26 +3,46 @@ import {MediaType, Charset} from '../common'; import {BaseSchema, parseAsync} from 'valibot'; export const getBody = ( - req: IncomingMessage, - schema: BaseSchema, - encodingPair?: Charset, - deserializer?: MediaType, + req: IncomingMessage, + schema: BaseSchema, + encodingPair?: Charset, + deserializer?: MediaType, ) => new Promise((resolve, reject) => { - let body = Buffer.from(''); - req.on('data', (chunk) => { - body = Buffer.concat([body, chunk]); - }); - req.on('end', async () => { - const bodyStr = encodingPair?.decode(body) ?? body.toString(); - try { - const bodyDeserialized = await parseAsync( - schema, - deserializer?.deserialize(bodyStr) ?? body, - {abortEarly: false}, - ); - resolve(bodyDeserialized); - } catch (err) { - reject(err); - } - }); + let body = Buffer.from(''); + req.on('data', (chunk) => { + body = Buffer.concat([body, chunk]); + }); + req.on('end', async () => { + const bodyStr = encodingPair?.decode(body) ?? body.toString(); + try { + const bodyDeserialized = await parseAsync( + schema, + deserializer?.deserialize(bodyStr) ?? body, + {abortEarly: false}, + ); + resolve(bodyDeserialized); + } catch (err) { + reject(err); + } + }); }); + +interface LinkMapEntry { + url: string; + params: Record; +} + +export class LinkMap extends Set { + toString() { + const entries = Array.from(this.values()); + + return entries.map((e) => { + const params = Object.entries(e.params); + + return [ + `<${encodeURIComponent(e.url)}>`, + ...params.map(([key, value]) => `${encodeURIComponent(key)}="${encodeURIComponent(value)}"`) + ].join(';') + }).join(','); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 5aa8842..73cdd1e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -16,9 +16,9 @@ export interface ClientState { } export interface ClientBuilder { - setLanguage(languageCode: ClientState['language']['name']): this; - setCharset(charset: ClientState['charset']['name']): this; - setMediaType(mediaType: ClientState['mediaType']['name']): this; + language(languageCode: ClientState['language']['name']): this; + charset(charset: ClientState['charset']['name']): this; + mediaTyoe(mediaType: ClientState['mediaType']['name']): this; } export interface CreateClientParams { @@ -34,18 +34,18 @@ export const createClient = (params: CreateClientParams) => { }; return { - setMediaType(mediaTypeName) { - const mediaType = Array.from(clientState.app.mediaTypes).find((l) => l.name === mediaTypeName); + mediaTyoe(mediaTypeName) { + const mediaType = clientState.app.mediaTypes.get(mediaTypeName); clientState.mediaType = mediaType ?? FALLBACK_MEDIA_TYPE; return this; }, - setCharset(charsetName) { - const charset = Array.from(clientState.app.charsets).find((l) => l.name === charsetName); + charset(charsetName) { + const charset = clientState.app.charsets.get(charsetName); clientState.charset = charset ?? FALLBACK_CHARSET; return this; }, - setLanguage(languageCode) { - const language = Array.from(clientState.app.languages).find((l) => l.name === languageCode); + language(languageCode) { + const language = clientState.app.languages.get(languageCode); clientState.language = language ?? FALLBACK_LANGUAGE; return this; } diff --git a/src/common/app.ts b/src/common/app.ts index e60ec9d..262b863 100644 --- a/src/common/app.ts +++ b/src/common/app.ts @@ -2,15 +2,77 @@ import {Resource} from './resource'; import {Language} from './language'; import {MediaType} from './media-type'; import {Charset} from './charset'; +import * as v from 'valibot'; +import {BackendBuilder, createBackend, CreateBackendParams} from '../backend'; +import {ClientBuilder, createClient, CreateClientParams} from '../client'; +import {FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE} from './index'; + +type ApplicationMap = Map; export interface ApplicationState { name: string; resources: Set>; - languages: Set; - mediaTypes: Set; - charsets: Set; + languages: ApplicationMap; + mediaTypes: ApplicationMap; + charsets: ApplicationMap; } export interface ApplicationParams { name: string; } + +export interface ApplicationBuilder { + mediaType(mediaType: MediaType): this; + language(language: Language): this; + charset(charset: Charset): this; + resource(resRaw: Resource): this; + createBackend(params: Omit): BackendBuilder; + createClient(params: Omit): ClientBuilder; +} + +export const application = (appParams: ApplicationParams): ApplicationBuilder => { + const appState: ApplicationState = { + name: appParams.name, + resources: new Set>(), + languages: new Map([ + [FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], + ]), + mediaTypes: new Map([ + [FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], + ]), + charsets: new Map([ + [FALLBACK_CHARSET.name, FALLBACK_CHARSET], + ]), + }; + + return { + mediaType(mediaType: MediaType) { + appState.mediaTypes.set(mediaType.name, mediaType); + return this; + }, + charset(charset: Charset) { + appState.charsets.set(charset.name, charset); + return this; + }, + language(language: Language) { + appState.languages.set(language.name, language); + return this; + }, + resource(resRaw: Resource) { + appState.resources.add(resRaw); + return this; + }, + createBackend(params: Omit) { + return createBackend({ + ...params, + app: appState + }); + }, + createClient(params: Omit) { + return createClient({ + ...params, + app: appState + }); + }, + }; +}; diff --git a/src/common/charset.ts b/src/common/charset.ts index 32c4cd8..7af75b2 100644 --- a/src/common/charset.ts +++ b/src/common/charset.ts @@ -3,3 +3,9 @@ export interface Charset { encode: (str: string) => Buffer; decode: (buf: Buffer) => string; } + +export const FALLBACK_CHARSET = { + encode: (str: string) => Buffer.from(str, 'utf-8'), + decode: (buf: Buffer) => buf.toString('utf-8'), + name: 'utf-8' as const, +} satisfies Charset; diff --git a/src/common/index.ts b/src/common/index.ts index 53c271f..24ad819 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -8,55 +8,8 @@ export * from './media-type'; export * from './resource'; export * from './language'; -export const FALLBACK_LANGUAGE = { - name: 'en' as const, - statusMessages: { - unableToSerializeResponse: 'Unable To Serialize Response', - unableToEncodeResponse: 'Unable To Encode Response', - unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', - unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', - unableToFetchResource: 'Unable To Fetch $RESOURCE', - unableToDeleteResource: 'Unable To Delete $RESOURCE', - languageNotAcceptable: 'Language Not Acceptable', - encodingNotAcceptable: 'Encoding Not Acceptable', - mediaTypeNotAcceptable: 'Media Type Not Acceptable', - methodNotAllowed: 'Method Not Allowed', - urlNotFound: 'URL Not Found', - badRequest: 'Bad Request', - ok: 'OK', - resourceCollectionFetched: '$RESOURCE Collection Fetched', - resourceFetched: '$RESOURCE Fetched', - resourceNotFound: '$RESOURCE Not Found', - deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', - resourceDeleted: '$RESOURCE Deleted', - unableToDeserializeRequest: 'Unable To Deserialize Request', - patchNonExistingResource: 'Patch Non-Existing $RESOURCE', - unableToPatchResource: 'Unable To Patch $RESOURCE', - invalidResourcePatch: 'Invalid $RESOURCE Patch', - invalidResource: 'Invalid $RESOURCE', - resourcePatched: '$RESOURCE Patched', - resourceCreated: '$RESOURCE Created', - resourceReplaced: '$RESOURCE Replaced', - unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', - unableToEmplaceResource: 'Unable To Emplace $RESOURCE', - resourceIdNotGiven: '$RESOURCE ID Not Given', - unableToCreateResource: 'Unable To Create $RESOURCE', - }, - bodies: { - languageNotAcceptable: [], - encodingNotAcceptable: [], - mediaTypeNotAcceptable: [] - }, -} satisfies Language; - -export const FALLBACK_CHARSET = { - encode: (str: string) => Buffer.from(str, 'utf-8'), - decode: (buf: Buffer) => buf.toString('utf-8'), - name: 'utf-8' as const, -} satisfies Charset; - -export const FALLBACK_MEDIA_TYPE = { - serialize: (obj: unknown) => JSON.stringify(obj), - deserialize: (str: string) => JSON.parse(str), - name: 'application/json' as const, -} satisfies MediaType; +export interface ContentNegotiation { + language: Language; + mediaType: MediaType; + charset: Charset; +} diff --git a/src/common/language.ts b/src/common/language.ts index 8848030..d1c1a1b 100644 --- a/src/common/language.ts +++ b/src/common/language.ts @@ -52,3 +52,44 @@ export interface Language { statusMessages: LanguageStatusMessageMap, bodies: LanguageBodyMap } + +export const FALLBACK_LANGUAGE = { + name: 'en' as const, + statusMessages: { + unableToSerializeResponse: 'Unable To Serialize Response', + unableToEncodeResponse: 'Unable To Encode Response', + unableToInitializeResourceDataSource: 'Unable To Initialize $RESOURCE Data Source', + unableToFetchResourceCollection: 'Unable To Fetch $RESOURCE Collection', + unableToFetchResource: 'Unable To Fetch $RESOURCE', + unableToDeleteResource: 'Unable To Delete $RESOURCE', + languageNotAcceptable: 'Language Not Acceptable', + encodingNotAcceptable: 'Encoding Not Acceptable', + mediaTypeNotAcceptable: 'Media Type Not Acceptable', + methodNotAllowed: 'Method Not Allowed', + urlNotFound: 'URL Not Found', + badRequest: 'Bad Request', + ok: 'OK', + resourceCollectionFetched: '$RESOURCE Collection Fetched', + resourceFetched: '$RESOURCE Fetched', + resourceNotFound: '$RESOURCE Not Found', + deleteNonExistingResource: 'Delete Non-Existing $RESOURCE', + resourceDeleted: '$RESOURCE Deleted', + unableToDeserializeRequest: 'Unable To Deserialize Request', + patchNonExistingResource: 'Patch Non-Existing $RESOURCE', + unableToPatchResource: 'Unable To Patch $RESOURCE', + invalidResourcePatch: 'Invalid $RESOURCE Patch', + invalidResource: 'Invalid $RESOURCE', + resourcePatched: '$RESOURCE Patched', + resourceCreated: '$RESOURCE Created', + resourceReplaced: '$RESOURCE Replaced', + unableToGenerateIdFromResourceDataSource: 'Unable To Generate ID From $RESOURCE Data Source', + unableToEmplaceResource: 'Unable To Emplace $RESOURCE', + resourceIdNotGiven: '$RESOURCE ID Not Given', + unableToCreateResource: 'Unable To Create $RESOURCE', + }, + bodies: { + languageNotAcceptable: [], + encodingNotAcceptable: [], + mediaTypeNotAcceptable: [] + }, +} satisfies Language; diff --git a/src/common/media-type.ts b/src/common/media-type.ts index a5733ca..b73302a 100644 --- a/src/common/media-type.ts +++ b/src/common/media-type.ts @@ -3,3 +3,9 @@ export interface MediaType { serialize: (object: T) => string; deserialize: (s: string) => T; } + +export const FALLBACK_MEDIA_TYPE = { + serialize: (obj: unknown) => JSON.stringify(obj), + deserialize: (str: string) => JSON.parse(str), + name: 'application/json' as const, +} satisfies MediaType; diff --git a/src/common/resource.ts b/src/common/resource.ts index e45d61b..2ab6a34 100644 --- a/src/common/resource.ts +++ b/src/common/resource.ts @@ -147,3 +147,7 @@ export const resource = < }, } as Resource; }; + +export type ResourceType = v.Output; + +export type ResourceTypeWithId = ResourceType & Record>; diff --git a/src/common/validation.ts b/src/common/validation.ts index abecf2c..1120a62 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -1,6 +1,5 @@ import * as v from 'valibot'; export * from 'valibot'; -import { Resource } from './resource'; export const datelike = () => v.transform( v.union([ @@ -12,7 +11,3 @@ export const datelike = () => v.transform( (value) => new Date(value).toISOString(), v.string([v.isoTimestamp()]) ); - -export type ResourceType = v.Output; - -export type ResourceTypeWithId = ResourceType & Record>; diff --git a/src/index.ts b/src/index.ts index 4536052..a2c399a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,3 @@ export * from './common'; export * as validation from './common/validation'; export * as dataSources from './backend/data-sources'; - -export * from './app'; diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts index 8955994..1bee371 100644 --- a/test/e2e/default.test.ts +++ b/test/e2e/default.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it, - test, } from 'vitest'; import { tmpdir @@ -93,9 +92,9 @@ describe('yasumi', () => { .createBackend({ dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir), }) - .throws404OnDeletingNotFound(); + .throwsErrorOnDeletingNotFound(); - server = backend.createServer({ + server = backend.createHttpServer({ basePath: '/api' }); @@ -750,13 +749,4 @@ describe('yasumi', () => { }); }); }); - - // https://github.com/mayajs/maya/blob/main/test/index.test.ts - // - // peak unit test - describe("Contribute to see a unit test", () => { - test("should have a unit test", () => { - expect("Is this a unit test?").not.toEqual("Yes this is a unit test."); - }); - }); });