From 392b842351c1be9c498d509a71e39c0dcc1d3376 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 23 May 2024 14:37:56 +0800 Subject: [PATCH] Implement recipe system Add basic recipe system for resources. --- packages/core/src/backend/common.ts | 7 +- packages/core/src/common/app.ts | 17 +++- packages/core/src/common/endpoint.ts | 1 + packages/core/src/common/index.ts | 2 + packages/core/src/common/recipe.ts | 20 +++++ packages/core/src/common/response.ts | 82 +++++++++++++++++++ .../http => common}/status-codes.ts | 0 .../core/src/extenders/http/backend/core.ts | 16 ++-- .../core/src/extenders/http/backend/index.ts | 1 - .../src/extenders/http/backend/response.ts | 67 --------------- packages/core/src/recipes/resource.ts | 52 ++++++++++++ packages/core/test/http/default.test.ts | 64 +++++++-------- packages/core/test/index.test.ts | 5 +- 13 files changed, 214 insertions(+), 120 deletions(-) create mode 100644 packages/core/src/common/recipe.ts create mode 100644 packages/core/src/common/response.ts rename packages/core/src/{extenders/http => common}/status-codes.ts (100%) delete mode 100644 packages/core/src/extenders/http/backend/response.ts create mode 100644 packages/core/src/recipes/resource.ts diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 9dea619..4a4d6a3 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -1,6 +1,5 @@ import {App as BaseApp, AppOperations, BaseAppState, Endpoint} from '../common'; - -export interface Response {} +import {Response} from '../common/response'; interface BackendParams { app: App; @@ -34,7 +33,9 @@ class BackendInstance implements Backend { operation: Operation, implementation: ImplementationFunction ) { - this.implementations.set(operation, implementation); + if (!this.implementations.has(operation)) { + this.implementations.set(operation, implementation); + } return this; } } diff --git a/packages/core/src/common/app.ts b/packages/core/src/common/app.ts index 7359c1c..723706c 100644 --- a/packages/core/src/common/app.ts +++ b/packages/core/src/common/app.ts @@ -58,12 +58,27 @@ class AppInstance implemen } operation(newOperation: NewOperation) { - this.operations.add(newOperation); + const existingOperations = Array.from(this.operations); + + if (!existingOperations.some((s) => ( + s.name === newOperation.name + && s.method === newOperation.method + ))) { + this.operations.add(newOperation); + } return this; } endpoint(newEndpoint: NewEndpoint) { + const existingEndpoints = Array.from(this.endpoints); + + if (existingEndpoints.some((s) => ( + s.name === newEndpoint.name + ))) { + throw new Error(`Cannot add duplicate endpoint with name: ${newEndpoint.name}`); + } + this.endpoints.add(newEndpoint); return this; diff --git a/packages/core/src/common/endpoint.ts b/packages/core/src/common/endpoint.ts index 26bca88..4c30772 100644 --- a/packages/core/src/common/endpoint.ts +++ b/packages/core/src/common/endpoint.ts @@ -38,6 +38,7 @@ export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set { + app: A; + backend?: Backend; + operations?: Record; + endpoints?: Record; +} + +export type Recipe = (a: RecipeState) => RecipeState; + +export const composeRecipes = (recipes: Recipe[]): Recipe => (params) => ( + recipes.reduce( + (rr, r) => r(rr), + params + ) +); diff --git a/packages/core/src/common/response.ts b/packages/core/src/common/response.ts new file mode 100644 index 0000000..efea672 --- /dev/null +++ b/packages/core/src/common/response.ts @@ -0,0 +1,82 @@ +import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; + +type FetchResponse = Awaited>; + +export interface Response { + statusCode: number; + statusMessage: string; + body?: Buffer; +} + +export interface ErrorResponse extends Error, Response {} + +export interface HttpResponseConstructor { + new (...args: any[]): R; + fromFetchResponse(response: FetchResponse): R; +} + +export interface HttpResponseErrorConstructor extends HttpResponseConstructor { + new (message?: string, options?: ErrorOptions): R; +} + +export interface HttpSuccessResponseConstructor extends HttpResponseConstructor { + new (response: Partial>): R; +} + +export interface HttpErrorOptions extends ErrorOptions { + body?: Response['body']; +} + +export const HttpResponse = < + T extends StatusCode = StatusCode, + R extends Response = T extends ErrorStatusCode ? ErrorResponse : Response, +>(statusCode: T): T extends ErrorStatusCode ? HttpResponseErrorConstructor : HttpSuccessResponseConstructor => { + if (isErrorStatusCode(statusCode)) { + return class HttpErrorResponse extends Error implements ErrorResponse { + readonly statusMessage: string; + readonly statusCode: T; + readonly body?: Buffer; + + constructor(message?: string, options?: HttpErrorOptions) { + super(message, options); + this.name = this.statusMessage = message ?? ''; + this.statusCode = statusCode; + this.cause = options?.cause; + this.body = options?.body; + } + } as unknown as HttpResponseErrorConstructor; + } + + return class HttpSuccessResponse implements Response { + readonly statusMessage: string; + readonly statusCode: T; + readonly body?: Buffer; + constructor(params: Partial>) { + this.statusCode = statusCode; + this.statusMessage = params.statusMessage ?? ''; + this.body = params.body; + } + + static fromFetchResponse(response: FetchResponse) { + return { + statusCode: response.status, + statusMessage: response.statusText, + deserialize: async () => { + if (response.status !== statusCode) { + throw new Error(`Status codes do not match: ${response.status} !== ${statusCode}`); + } + + const contentType = response.headers.get('Content-Type'); + // TODO properly parse media type + if (contentType === 'application/json') { + return await response.json(); + } + + const buffer = await response.arrayBuffer(); + return buffer; + // TODO deserialize buffer + }, + }; + } + } as unknown as HttpSuccessResponseConstructor; +}; diff --git a/packages/core/src/extenders/http/status-codes.ts b/packages/core/src/common/status-codes.ts similarity index 100% rename from packages/core/src/extenders/http/status-codes.ts rename to packages/core/src/common/status-codes.ts diff --git a/packages/core/src/extenders/http/backend/core.ts b/packages/core/src/extenders/http/backend/core.ts index dfa7ac4..ae81f71 100644 --- a/packages/core/src/extenders/http/backend/core.ts +++ b/packages/core/src/extenders/http/backend/core.ts @@ -13,10 +13,6 @@ declare module '../../../backend' { interface ServerRequest extends http.IncomingMessage {} interface ServerResponse extends http.ServerResponse {} - - interface ImplementationContext { - res: ServerResponse; - } } class ServerInstance implements Server { @@ -81,11 +77,14 @@ class ServerInstance implements Server { const [, search] = req.url.split('?'); + // TODO get content negotiation params + + // TODO add flag on implementation context if CQRS should be enabled + const responseSpec = await implementation({ endpoint, params: endpointParams ?? {}, query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, - res, }); if (typeof responseSpec === 'undefined') { @@ -94,10 +93,9 @@ class ServerInstance implements Server { return; } - const finalRes = responseSpec.res ?? res; - finalRes.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code - finalRes.writeHead(responseSpec.statusCode, {}); - finalRes.end(); + res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code + res.writeHead(responseSpec.statusCode, {}); + res.end(); }; serve(params: ServiceParams) { diff --git a/packages/core/src/extenders/http/backend/index.ts b/packages/core/src/extenders/http/backend/index.ts index 9b080f4..4b0e041 100644 --- a/packages/core/src/extenders/http/backend/index.ts +++ b/packages/core/src/extenders/http/backend/index.ts @@ -1,2 +1 @@ export * from './core'; -export * from './response'; diff --git a/packages/core/src/extenders/http/backend/response.ts b/packages/core/src/extenders/http/backend/response.ts deleted file mode 100644 index 37e3c13..0000000 --- a/packages/core/src/extenders/http/backend/response.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {Response} from '../../../backend'; -import {ErrorStatusCode, isErrorStatusCode, StatusCode} from '../status-codes'; -import http from 'http'; - -declare module '../../../backend' { - interface Response { - statusCode: number; - statusMessage: string; - body?: Buffer; - res?: http.ServerResponse; - } -} - -interface ErrorResponse extends Error, Response {} - -interface HttpResponseConstructor { - new (...args: any[]): R; -} - -interface HttpResponseErrorConstructor extends HttpResponseConstructor { - new (message?: string, options?: ErrorOptions): R; -} - -interface HttpSuccessResponseConstructor extends HttpResponseConstructor { - new (response: Partial>, options?: Pick): R; -} - -interface HttpErrorOptions extends ErrorOptions { - res?: http.ServerResponse; - body?: Response['body']; -} - -export const HttpResponse = < - T extends StatusCode = StatusCode, - R extends Response = T extends ErrorStatusCode ? ErrorResponse : Response, ->(statusCode: T): T extends ErrorStatusCode ? HttpResponseErrorConstructor : HttpSuccessResponseConstructor => { - if (isErrorStatusCode(statusCode)) { - return class HttpErrorResponse extends Error implements ErrorResponse { - readonly statusMessage: string; - readonly statusCode: T; - readonly res?: http.ServerResponse; - readonly body?: Buffer; - - constructor(message?: string, options?: HttpErrorOptions) { - super(message, options); - this.name = this.statusMessage = message ?? ''; - this.statusCode = statusCode; - this.cause = options?.cause; - this.res = options?.res; - this.body = options?.body; - } - } as unknown as HttpResponseErrorConstructor; - } - - return class HttpSuccessResponse implements Response { - readonly statusMessage: string; - readonly statusCode: T; - readonly body?: Buffer; - readonly res?: http.ServerResponse; - constructor(params: Partial>, options?: Pick) { - this.statusCode = statusCode; - this.statusMessage = params.statusMessage ?? ''; - this.body = params.body; - this.res = options?.res; - } - } as unknown as HttpSuccessResponseConstructor; -}; diff --git a/packages/core/src/recipes/resource.ts b/packages/core/src/recipes/resource.ts new file mode 100644 index 0000000..c366ef4 --- /dev/null +++ b/packages/core/src/recipes/resource.ts @@ -0,0 +1,52 @@ +import {Recipe} from '../common/recipe'; +import {endpoint, HttpResponse, operation, validation as v} from '../common'; +import {backend} from '../backend'; + +interface AddResourceRecipeParams { + endpointName: string; +} + +export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a) => { + const operations = { + fetch: operation({ + name: 'fetch' as const, + }), + }; + + const theEndpoint = endpoint({ + name: params.endpointName, + schema: v.object({ + username: v.string(), + }), + }) + .param('resourceId') + .can('fetch'); + + const enhancedApp = a.app + .operation(operations.fetch) + .endpoint(theEndpoint); + + const theBackend = a.backend ?? backend({ + app: enhancedApp, + }); + + theBackend + .implementOperation('fetch', async (ctx) => { + // need to genericise the response here so we don't depend on the HTTP responses. + + return new YesResponse({ + statusMessage: 'Yes', + }); + }); + + return { + operations, + app: enhancedApp, + backend: theBackend, + endpoints: { + [params.endpointName]: theEndpoint, + }, + }; +}; + +export class YesResponse extends HttpResponse(204) {} diff --git a/packages/core/test/http/default.test.ts b/packages/core/test/http/default.test.ts index 1fff887..e4dddd2 100644 --- a/packages/core/test/http/default.test.ts +++ b/packages/core/test/http/default.test.ts @@ -6,50 +6,42 @@ import { expect, } from 'vitest'; -import {app, endpoint, Endpoint, operation, Operation, validation as v} from '../../src/common'; -import {Server, backend} from '../../src/backend'; +import { + app, + Endpoint, + Operation, +} from '../../src/common'; +import {Server} from '../../src/backend'; import {Client} from '../../src/client'; -import {server, HttpResponse} from '../../src/extenders/http/backend'; +import {server} from '../../src/extenders/http/backend'; import {client} from '../../src/extenders/http/client'; +import {composeRecipes} from '../../src/common/recipe'; +import {addResourceRecipe, YesResponse} from '../../src/recipes/resource'; describe('default', () => { let theClient: Client; let theServer: Server; - let theEndpoint: Endpoint; + let theRawEndpoint: Endpoint; let theOperation: Operation; beforeAll(async () => { - theOperation = operation({ - name: 'fetch' as const, - }); - - theEndpoint = endpoint({ - name: 'users' as const, - schema: v.object({ - username: v.string(), - }), - }) - .param('resourceId'); - - const theApp = app({ + const theRawApp = app({ name: 'default' as const, - }) - .operation(theOperation) - .endpoint(theEndpoint); + }); - const theBackend = backend({ + const { app: theApp, + operations, + backend: theBackend, + } = composeRecipes([ + addResourceRecipe({ endpointName: 'users' }), + addResourceRecipe({ endpointName: 'posts' }) + ])({ + app: theRawApp, }); - theBackend.implementOperation('fetch', async (ctx) => { - class YesResponse extends HttpResponse(204) {} - - return new YesResponse({ - statusMessage: 'Yes', - }, { - res: ctx.res, - }); - }); + theRawEndpoint = Array.from(theApp.endpoints).find((e) => e.name === 'users'); + theOperation = operations.fetch; theServer = server({ backend: theBackend, @@ -74,21 +66,21 @@ describe('default', () => { }); it('works', async () => { - theEndpoint.can('fetch'); - // TODO create wrapper for fetch's Response here // // should we create a helper object to process client-side received response from server's sent response? // // the motivation is to remove the manual deserialization from the client (provide serialization on the response // object so as the client is not limited to .text(), .json(), .arrayBuffer() etc) - const response = await theClient - .at(theEndpoint) + const responseRaw = await theClient + .at(theRawEndpoint) .makeRequest(theOperation, new URLSearchParams({ foo: 'bar', })); - expect(response).toHaveProperty('status', 204); - expect(response).toHaveProperty('statusText', 'Yes'); + const response = YesResponse.fromFetchResponse(responseRaw); + + expect(response).toHaveProperty('statusCode', 204); + expect(response).toHaveProperty('statusMessage', 'Yes'); }); }); diff --git a/packages/core/test/index.test.ts b/packages/core/test/index.test.ts index 80d7869..f69470a 100644 --- a/packages/core/test/index.test.ts +++ b/packages/core/test/index.test.ts @@ -2,17 +2,16 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; import { App, app, - AppOperations, DataSource, Endpoint, - endpoint, EndpointOperations, + endpoint, Operation, operation, validation as v, } from '../src/common'; import {Backend, backend, Server} from '../src/backend'; import {Client} from '../src/client'; -import {server} from '../src/extenders/http/backend/core'; +import {server} from '../src/extenders/http/backend'; import {client} from '../src/extenders/http/client'; const op = operation({