diff --git a/README.md b/README.md index 5e682e6..b917679 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ See [docs folder](./docs) for more details. ## Links +- Representational State Transfer + + https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm + - Roy Fielding (creator of REST)'s post about criticisms of REST APIs https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven diff --git a/examples/basic/server.ts b/examples/basic/server.ts index bcd5c59..5cf1e07 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -1,8 +1,9 @@ import { - application, dataSources, + application, resource, validation as v, -} from '../../src'; +} from '../../src/common'; +import { dataSources } from '../../src/backend'; import {TEXT_SERIALIZER_PAIR} from './serializers'; import {autoIncrement} from './data-source'; diff --git a/pridepack.json b/pridepack.json index 52d656d..0ef3915 100644 --- a/pridepack.json +++ b/pridepack.json @@ -1,7 +1,7 @@ { "target": "es2018", "entrypoints": { - ".": "src/index.ts", + ".": "src/common/index.ts", "./backend": "src/backend/index.ts", "./client": "src/client/index.ts" } diff --git a/src/backend/core.ts b/src/backend/core.ts index c48246f..b8138b7 100644 --- a/src/backend/core.ts +++ b/src/backend/core.ts @@ -1,7 +1,5 @@ import {ApplicationState, FALLBACK_CHARSET, FALLBACK_LANGUAGE, FALLBACK_MEDIA_TYPE, Resource} from '../common'; -import http from 'http'; -import {createServer, CreateServerParams} from './servers/http'; -import https from 'https'; +import {createServer, CreateServerParams, Server} from './servers/http'; import {BackendState} from './common'; import {DataSource} from './data-source'; @@ -10,7 +8,7 @@ export interface BackendBuilder { showTotalItemCountOnCreateItem(b?: boolean): this; checksSerializersOnDelete(b?: boolean): this; throwsErrorOnDeletingNotFound(b?: boolean): this; - createHttpServer(serverParams?: CreateServerParams): http.Server | https.Server; + createHttpServer(serverParams?: CreateServerParams): Server; dataSource?: (resource: Resource) => T; } diff --git a/src/backend/data-sources/file-jsonl.ts b/src/backend/data-sources/file-jsonl.ts index c93ae3d..ce3220d 100644 --- a/src/backend/data-sources/file-jsonl.ts +++ b/src/backend/data-sources/file-jsonl.ts @@ -1,17 +1,10 @@ import {readFile, writeFile} from 'fs/promises'; import {join} from 'path'; import {DataSource as DataSourceInterface, ResourceIdConfig} from '../data-source'; -import {Resource} from '../..'; +import {Resource} from '../../common'; import * as v from 'valibot'; -declare module '../..' { - - - interface BaseResourceState { - idAttr: string; - idConfig: ResourceIdConfig - } - +declare module '../../common' { interface Resource< Schema extends v.BaseSchema = v.BaseSchema, CurrentName extends string = string, diff --git a/src/backend/servers/http/core.ts b/src/backend/servers/http/core.ts index 64f600d..9bbf997 100644 --- a/src/backend/servers/http/core.ts +++ b/src/backend/servers/http/core.ts @@ -6,8 +6,8 @@ import { AllowedMiddlewareSpecification, BackendState, Middleware, - RequestContext, - Response + RequestContext, RequestDecorator, + Response, } from '../../common'; import {Resource} from '../../../common'; import { @@ -142,7 +142,24 @@ class CqrsEventEmitter extends EventEmitter { } +interface ServerState { + requestDecorators: Set; +} + +export interface Server { + readonly listening: boolean; + on(event: string, cb: (...args: unknown[]) => unknown): this; + close(callback?: (err?: Error) => void): this; + listen(...args: Parameters): this; + requestDecorator(requestDecorator: RequestDecorator): this; + defaultErrorHandler(): this; +} + export const createServer = (backendState: BackendState, serverParams = {} as CreateServerParams) => { + const state: ServerState = { + requestDecorators: new Set(), + }; + const isHttps = 'key' in serverParams && 'cert' in serverParams; const theRes = new CqrsEventEmitter(); @@ -156,12 +173,6 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr requestTimeout: serverParams.requestTimeout, }); - const defaultRequestDecorators = [ - decorateRequestWithMethod, - decorateRequestWithUrl(serverParams), - decorateRequestWithBackend(backendState), - ]; - const handleMiddlewares = async (currentHandlerState: Awaited>, currentMiddleware: AllowedMiddlewareSpecification, req: ResourceRequestContext) => { const { method: middlewareMethod, middleware, constructBodySchema} = currentMiddleware; const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; @@ -305,9 +316,18 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr return middlewareResponse as Awaited> }; + const defaultRequestDecorators = [ + decorateRequestWithMethod, + decorateRequestWithUrl(serverParams), + decorateRequestWithBackend(backendState), + ]; + const decorateRequest = async (reqRaw: http.IncomingMessage) => { - // TODO custom decorators - const effectiveRequestDecorators = defaultRequestDecorators; + const effectiveRequestDecorators = [ + ...defaultRequestDecorators, + ...Array.from(state.requestDecorators), + ]; + return await effectiveRequestDecorators.reduce( async (resultRequestPromise, decorator) => { const resultRequest = await resultRequestPromise; @@ -319,10 +339,51 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ); }; + const handleMiddlewareError = (processRequestErrRaw: Error) => (resourceReq: ResourceRequestContext, res: http.ServerResponse) => { + const finalErr = processRequestErrRaw as ErrorPlainResponse; + const headers = finalErr.headers ?? {}; + let encoded: Buffer | undefined; + let serialized; + try { + serialized = typeof finalErr.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.body) : undefined; + } catch (cause) { + res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace( + /\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + res.end(); + return; + } + + try { + encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined; + } catch (cause) { + res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, + resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); + res.end(); + } + + headers['Content-Type'] = [ + resourceReq.backend.cn.mediaType.name, + `charset=${resourceReq.backend.cn.charset.name}`, + ].join('; '); + + const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined; + res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; + res.writeHead(finalErr.statusCode, headers); + if (typeof encoded !== 'undefined') { + res.end(encoded); + return; + } + res.end(); + }; + const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { const plainReq = await decorateRequest(reqRaw); // TODO add type safety here, put handleGetRoot as its own middleware as it does not concern over any resource if (typeof plainReq.resource !== 'undefined') { const resourceReq = plainReq as ResourceRequestContext; + // TODO custom middlewares const effectiveMiddlewares = ( typeof resourceReq.resourceId === 'string' ? defaultItemMiddlewares @@ -335,43 +396,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this } catch (processRequestErrRaw) { - const finalErr = processRequestErrRaw as ErrorPlainResponse; - const headers = finalErr.headers ?? {}; - let encoded: Buffer | undefined; - let serialized; - try { - serialized = typeof finalErr.body !== 'undefined' ? resourceReq.backend.cn.mediaType.serialize(finalErr.body) : undefined; - } catch (cause) { - res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToSerializeResponse']?.replace( - /\$RESOURCE/g, - resourceReq.resource!.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - return; - } - - try { - encoded = typeof serialized !== 'undefined' ? resourceReq.backend.cn.charset.encode(serialized) : undefined; - } catch (cause) { - res.statusMessage = resourceReq.backend.cn.language.statusMessages['unableToEncodeResponse']?.replace(/\$RESOURCE/g, - resourceReq.resource!.state.itemName) ?? ''; - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); - res.end(); - } - - headers['Content-Type'] = [ - resourceReq.backend.cn.mediaType.name, - `charset=${resourceReq.backend.cn.charset.name}`, - ].join('; '); - - const statusMessageKey = finalErr.statusMessage ? resourceReq.backend.cn.language.statusMessages[finalErr.statusMessage] : undefined; - res.statusMessage = statusMessageKey?.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; - res.writeHead(finalErr.statusCode, headers); - if (typeof encoded !== 'undefined') { - res.end(encoded); - return; - } - res.end(); + handleMiddlewareError(processRequestErrRaw as Error)(resourceReq, res); return; } @@ -476,5 +501,28 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr server.on('request', handleRequest); - return server; + return { + get listening() { return server.listening }, + listen(...args: Parameters) { + server.listen(...args); + return this; + }, + close(callback?: (err?: Error) => void) { + server.close(callback); + return this; + }, + on(...args: Parameters) { + server.on(args[0], args[1]); + return this; + }, + requestDecorator(requestDecorator: RequestDecorator) { + state.requestDecorators.add(requestDecorator); + return this; + }, + defaultErrorHandler() { + return this; + } + } satisfies Server; + + // return server; } diff --git a/src/backend/servers/http/index.ts b/src/backend/servers/http/index.ts index 4b0e041..9b080f4 100644 --- a/src/backend/servers/http/index.ts +++ b/src/backend/servers/http/index.ts @@ -1 +1,2 @@ export * from './core'; +export * from './response'; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index d0b9323..0000000 --- a/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './common'; diff --git a/test/e2e/features.test.ts b/test/e2e/features.test.ts index d92ff22..dcb7c41 100644 --- a/test/e2e/features.test.ts +++ b/test/e2e/features.test.ts @@ -2,10 +2,10 @@ import {describe, afterAll, afterEach, beforeAll, beforeEach, it} from 'vitest'; import {mkdtemp, rm} from 'fs/promises'; import {join} from 'path'; import {tmpdir} from 'os'; -import {application, resource, Resource, validation as v} from '../../src'; -import {dataSources} from '../../src/backend'; -import {Server} from 'http'; +import {application, resource, Resource, validation as v} from '../../src/common'; +import {BackendBuilder, dataSources} from '../../src/backend'; import {autoIncrement} from '../fixtures'; +import {RequestContext} from '../../src/backend/common'; const PORT = 3001; const HOST = '127.0.0.1'; @@ -52,7 +52,7 @@ describe('decorators', () => { }); }); - let server: Server; + let server: ReturnType; beforeEach(() => { const app = application({ name: 'piano-service', @@ -95,6 +95,12 @@ describe('decorators', () => { })); it('decorates requests', () => { + server.requestDecorator((req) => { + const reqMut = req as unknown as Record; + reqMut['foo'] = 'bar'; + return reqMut as unknown as RequestContext; + }); + // TODO how to make assertions here }); }); diff --git a/test/e2e/http.test.ts b/test/e2e/http.test.ts index 0623211..935878e 100644 --- a/test/e2e/http.test.ts +++ b/test/e2e/http.test.ts @@ -18,10 +18,10 @@ import { import { join } from 'path'; -import {request, Server} from 'http'; +import {request} from 'http'; import {constants} from 'http2'; import {BackendBuilder, dataSources} from '../../src/backend'; -import { application, resource, validation as v, Resource } from '../../src'; +import { application, resource, validation as v, Resource } from '../../src/common'; import { autoIncrement } from '../fixtures'; const PORT = 3000; @@ -70,7 +70,7 @@ describe('yasumi HTTP', () => { }); let backend: BackendBuilder; - let server: Server; + let server: ReturnType; beforeEach(() => { const app = application({ name: 'piano-service',