From feb105d2926b7cc12e01a56580886c24396dc5c0 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Fri, 24 May 2024 12:28:19 +0800 Subject: [PATCH] Implement resource query Add implementation for fetch method in tests. --- packages/core/src/backend/common.ts | 5 ++ packages/core/src/backend/data-source.ts | 37 ++++++++ packages/core/src/backend/index.ts | 1 + packages/core/src/common/data-source.ts | 2 - packages/core/src/common/endpoint.ts | 5 +- packages/core/src/common/index.ts | 2 +- packages/core/src/common/queries/common.ts | 53 ++++++++++++ packages/core/src/common/queries/index.ts | 1 + packages/core/src/common/recipe.ts | 3 +- packages/core/src/common/response.ts | 27 +++--- .../core/src/extenders/http/backend/core.ts | 46 ++++++---- packages/core/src/recipes/resource.ts | 52 ------------ packages/core/src/recipes/resource/core.ts | 84 +++++++++++++++++++ packages/core/src/recipes/resource/index.ts | 2 + .../core/src/recipes/resource/response.ts | 9 ++ packages/core/test/http/default.test.ts | 54 ++++++++++-- packages/core/test/index.test.ts | 3 +- 17 files changed, 289 insertions(+), 97 deletions(-) create mode 100644 packages/core/src/backend/data-source.ts delete mode 100644 packages/core/src/common/data-source.ts create mode 100644 packages/core/src/common/queries/common.ts create mode 100644 packages/core/src/common/queries/index.ts delete mode 100644 packages/core/src/recipes/resource.ts create mode 100644 packages/core/src/recipes/resource/core.ts create mode 100644 packages/core/src/recipes/resource/index.ts create mode 100644 packages/core/src/recipes/resource/response.ts diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 84a930c..ecb987e 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -1,7 +1,9 @@ import {App as BaseApp, AppOperations, BaseAppState, Endpoint, Response} from '../common'; +import {DataSource} from './data-source'; interface BackendParams { app: App; + dataSource?: DataSource; } export interface ImplementationContext { @@ -14,6 +16,7 @@ type ImplementationFunction = (params: ImplementationContext) => Promise { app: App; + dataSource?: DataSource; implementations: Map; implementOperation>( operation: Operation, implementation: ImplementationFunction): this; @@ -21,10 +24,12 @@ export interface Backend { class BackendInstance implements Backend { readonly app: App; + readonly dataSource?: DataSource; readonly implementations: Map; constructor(params: BackendParams) { this.app = params.app; + this.dataSource = params.dataSource; this.implementations = new Map(); } diff --git a/packages/core/src/backend/data-source.ts b/packages/core/src/backend/data-source.ts new file mode 100644 index 0000000..dbed912 --- /dev/null +++ b/packages/core/src/backend/data-source.ts @@ -0,0 +1,37 @@ +import {QueryAndGrouping, validation as v} from '../common'; + +export interface EmplaceDetails { + isCreated: boolean; +} + +export type DataSourceQuery = QueryAndGrouping; + +export interface DataSource< + ItemData extends object = object, + ID extends unknown = unknown, + Query extends DataSourceQuery = DataSourceQuery, + Emplace extends EmplaceDetails = EmplaceDetails, + DeleteResult = unknown, +> { + initialize(): Promise; + getTotalCount?(query?: Query): Promise; + getMultiple(query?: Query): Promise; + getById(id: ID): Promise; + getSingle?(query?: Query): Promise; + create(data: ItemData): Promise; + delete(id: ID): Promise; + emplace(id: ID, data: ItemData): Promise<[ItemData, Emplace]>; + patch(id: ID, data: Partial): Promise; + newId(): Promise; +} + +export interface ResourceIdConfig { + generationStrategy: GenerationStrategy; + serialize: (id: unknown) => string; + deserialize: (id: string) => v.Output; + schema: IdSchema; +} + +export interface GenerationStrategy { + (dataSource: DataSource, ...args: unknown[]): Promise; +} diff --git a/packages/core/src/backend/index.ts b/packages/core/src/backend/index.ts index bbeac2e..e9c96c9 100644 --- a/packages/core/src/backend/index.ts +++ b/packages/core/src/backend/index.ts @@ -1,2 +1,3 @@ export * from './common'; +export * from './data-source'; export * from './server'; diff --git a/packages/core/src/common/data-source.ts b/packages/core/src/common/data-source.ts deleted file mode 100644 index 3387d37..0000000 --- a/packages/core/src/common/data-source.ts +++ /dev/null @@ -1,2 +0,0 @@ - -export interface DataSource {} diff --git a/packages/core/src/common/endpoint.ts b/packages/core/src/common/endpoint.ts index e1ef5c3..2047fd4 100644 --- a/packages/core/src/common/endpoint.ts +++ b/packages/core/src/common/endpoint.ts @@ -1,4 +1,4 @@ -import {DataSource} from './data-source'; +import {DataSource} from '../backend/data-source'; import {validation as v} from '.'; export type EndpointQueue = [Endpoint, Record | undefined][]; @@ -87,6 +87,7 @@ export interface Endpoint< State extends BaseEndpointState = BaseEndpointState > { name: Name; + dataSource?: DataSource; schema: Schema; params: Set; operations: Set; @@ -126,6 +127,7 @@ class EndpointInstance< State extends BaseEndpointState > implements Endpoint { readonly name: Params['name']; + readonly dataSource: Params['dataSource']; readonly operations: Set; readonly params: Set; readonly schema: Params['schema']; @@ -135,6 +137,7 @@ class EndpointInstance< this.schema = params.schema; this.operations = new Set(); this.params = new Set(); + this.dataSource = params.dataSource; } can( diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 6f151cf..c963675 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -1,10 +1,10 @@ export * from './app'; export * from './charset'; -export * from './data-source'; export * from './endpoint'; export * from './language'; export * from './media-type'; export * from './operation'; +export * from './queries'; export * from './response'; export * from './service'; export * as statusCodes from './status-codes'; diff --git a/packages/core/src/common/queries/common.ts b/packages/core/src/common/queries/common.ts new file mode 100644 index 0000000..fe462d9 --- /dev/null +++ b/packages/core/src/common/queries/common.ts @@ -0,0 +1,53 @@ +import {MediaType} from '../media-type'; + +const OPERATORS = [ + '=', + '!=', + '>=', + '<=', + '>', + '<', + 'LIKE', + 'ILIKE', + 'REGEXP', +] as const; + +type QueryOperator = typeof OPERATORS[number]; + +type QueryExpressionValue = string | number | boolean; + +interface QueryOperatorExpression { + lhs: string; + operator: QueryOperator; + rhs: QueryExpressionValue | RegExp; +} + +interface QueryFunctionExpression { + name: string; + args: QueryExpressionValue[]; +} + +export type QueryAnyExpression = QueryOperatorExpression | QueryFunctionExpression; + +export interface QueryOrGrouping { + type: 'or'; + expressions: QueryAnyExpression[]; +} + +export interface QueryAndGrouping { + type: 'and'; + expressions: QueryOrGrouping[]; +} + +export type Query = QueryAndGrouping; + +export interface QueryMediaType< + Name extends string = string, + SerializeOptions extends {} = {}, + DeserializeOptions extends {} = {} +> extends MediaType< + Name, + QueryAndGrouping, + SerializeOptions, + DeserializeOptions +> {} diff --git a/packages/core/src/common/queries/index.ts b/packages/core/src/common/queries/index.ts new file mode 100644 index 0000000..d0b9323 --- /dev/null +++ b/packages/core/src/common/queries/index.ts @@ -0,0 +1 @@ +export * from './common'; diff --git a/packages/core/src/common/recipe.ts b/packages/core/src/common/recipe.ts index 114bdd4..a591cf9 100644 --- a/packages/core/src/common/recipe.ts +++ b/packages/core/src/common/recipe.ts @@ -1,4 +1,4 @@ -import {Backend} from '../backend'; +import {Backend, DataSource} from '../backend'; import {Operation} from './operation'; import {App} from './app'; import {Endpoint} from './endpoint'; @@ -6,6 +6,7 @@ import {Endpoint} from './endpoint'; export interface RecipeState { app: A; backend?: Backend; + dataSource?: DataSource; operations?: Record; endpoints?: Record; } diff --git a/packages/core/src/common/response.ts b/packages/core/src/common/response.ts index efea672..f09dc70 100644 --- a/packages/core/src/common/response.ts +++ b/packages/core/src/common/response.ts @@ -2,13 +2,13 @@ import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; type FetchResponse = Awaited>; -export interface Response { +export interface Response { statusCode: number; statusMessage: string; - body?: Buffer; + body?: B; } -export interface ErrorResponse extends Error, Response {} +export interface ErrorResponse extends Error, Response {} export interface HttpResponseConstructor { new (...args: any[]): R; @@ -20,24 +20,25 @@ export interface HttpResponseErrorConstructor extends HttpRe } export interface HttpSuccessResponseConstructor extends HttpResponseConstructor { - new (response: Partial>): R; + new (response: Partial>): R; } -export interface HttpErrorOptions extends ErrorOptions { - body?: Response['body']; +export interface HttpErrorOptions extends ErrorOptions { + body?: B; } export const HttpResponse = < + B extends unknown = unknown, T extends StatusCode = StatusCode, - R extends Response = T extends ErrorStatusCode ? ErrorResponse : Response, + 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 { + return class HttpErrorResponse extends Error implements ErrorResponse { readonly statusMessage: string; readonly statusCode: T; - readonly body?: Buffer; + readonly body?: B; - constructor(message?: string, options?: HttpErrorOptions) { + constructor(message?: string, options?: HttpErrorOptions) { super(message, options); this.name = this.statusMessage = message ?? ''; this.statusCode = statusCode; @@ -47,11 +48,11 @@ export const HttpResponse = < } as unknown as HttpResponseErrorConstructor; } - return class HttpSuccessResponse implements Response { + return class HttpSuccessResponse implements Response { readonly statusMessage: string; readonly statusCode: T; - readonly body?: Buffer; - constructor(params: Partial>) { + readonly body?: B; + constructor(params: Partial, 'statusCode'>>) { this.statusCode = statusCode; this.statusMessage = params.statusMessage ?? ''; this.body = params.body; diff --git a/packages/core/src/extenders/http/backend/core.ts b/packages/core/src/extenders/http/backend/core.ts index 50a2277..555ccf0 100644 --- a/packages/core/src/extenders/http/backend/core.ts +++ b/packages/core/src/extenders/http/backend/core.ts @@ -1,18 +1,18 @@ -import {parseToEndpointQueue, ServiceParams} from '../../../common'; +import {ErrorResponse, parseToEndpointQueue, ServiceParams} from '../../../common'; import { Backend as BaseBackend, Server, - ServerRequest, - ServerResponse, + ServerRequestContext, + ServerResponseContext, ServerParams, } from '../../../backend'; import http from 'http'; import { statusCodes } from '../../../common'; declare module '../../../backend' { - interface ServerRequest extends http.IncomingMessage {} + interface ServerRequestContext extends http.IncomingMessage {} - interface ServerResponse extends http.ServerResponse {} + interface ServerResponseContext extends http.ServerResponse {} } class ServerInstance implements Server { @@ -23,7 +23,7 @@ class ServerInstance implements Server { this.backend = params.backend; } - private readonly requestListener = async (req: ServerRequest, res: ServerResponse) => { + private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => { // const endpoints = this.backend.app.endpoints; if (typeof req.method === 'undefined') { res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); @@ -81,21 +81,31 @@ class ServerInstance implements Server { // 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, - }); + try { + const responseSpec = await implementation({ + endpoint, + params: endpointParams ?? {}, + query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, + }); + + if (typeof responseSpec === 'undefined') { + res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {}); + res.end(); + return; + } - if (typeof responseSpec === 'undefined') { - res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {}); + const bodyToSerialize = responseSpec.body; + console.log(bodyToSerialize); + + res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code + res.writeHead(responseSpec.statusCode, {}); + res.end(); + } catch (errorResponseSpecRaw) { + const responseSpec = errorResponseSpecRaw as ErrorResponse; + res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code + res.writeHead(responseSpec.statusCode, {}); res.end(); - return; } - - 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/recipes/resource.ts b/packages/core/src/recipes/resource.ts deleted file mode 100644 index c366ef4..0000000 --- a/packages/core/src/recipes/resource.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/src/recipes/resource/core.ts b/packages/core/src/recipes/resource/core.ts new file mode 100644 index 0000000..f3dc6b8 --- /dev/null +++ b/packages/core/src/recipes/resource/core.ts @@ -0,0 +1,84 @@ +import {Recipe} from '../../common/recipe'; +import {endpoint, operation, validation as v} from '../../common'; +import {backend, DataSource} from '../../backend'; +import { + DataSourceNotFoundResponseError, + ItemNotFoundReponseError, + ResourceCollectionFetchedResponse, + ResourceItemFetchedResponse, +} from './response'; + +interface AddResourceRecipeParams { + endpointName: string; + dataSource?: DataSource; +} + +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, + dataSource: params.dataSource, + }); + + theBackend + .implementOperation('fetch', async (ctx) => { + const dataSource: DataSource = ctx.endpoint.dataSource ?? theBackend.dataSource ?? {} as DataSource; + // need to genericise the response here so we don't depend on the HTTP responses. + const { resourceId } = ctx.params; + const { getById, getMultiple } = dataSource; + + if (typeof resourceId === 'undefined') { + if (typeof getMultiple === 'undefined') { + throw new DataSourceNotFoundResponseError(); + } + + // TODO add query here + const items = await getMultiple(); + return new ResourceCollectionFetchedResponse({ + statusMessage: 'Resource Collection Fetched', + body: items, + }); + } + + if (typeof getById === 'undefined') { + throw new DataSourceNotFoundResponseError(); + } + + const item = await getById(resourceId); + if (!item) { + throw new ItemNotFoundReponseError(); + } + + return new ResourceItemFetchedResponse({ + statusMessage: 'Resource Item Fetched', + body: item, + }); + }); + + return { + operations, + app: enhancedApp, + backend: theBackend, + endpoints: { + [params.endpointName]: theEndpoint, + }, + }; +}; diff --git a/packages/core/src/recipes/resource/index.ts b/packages/core/src/recipes/resource/index.ts new file mode 100644 index 0000000..9b080f4 --- /dev/null +++ b/packages/core/src/recipes/resource/index.ts @@ -0,0 +1,2 @@ +export * from './core'; +export * from './response'; diff --git a/packages/core/src/recipes/resource/response.ts b/packages/core/src/recipes/resource/response.ts new file mode 100644 index 0000000..50a11fe --- /dev/null +++ b/packages/core/src/recipes/resource/response.ts @@ -0,0 +1,9 @@ +import {HttpResponse, statusCodes} from '../../common'; + +export class ResourceItemFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} + +export class ResourceCollectionFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} + +export class DataSourceNotFoundResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} + +export class ItemNotFoundReponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_FOUND) {} diff --git a/packages/core/test/http/default.test.ts b/packages/core/test/http/default.test.ts index e4dddd2..d599241 100644 --- a/packages/core/test/http/default.test.ts +++ b/packages/core/test/http/default.test.ts @@ -4,6 +4,7 @@ import { afterAll, it, expect, + vi, Mock, } from 'vitest'; import { @@ -11,18 +12,38 @@ import { Endpoint, Operation, } from '../../src/common'; -import {Server} from '../../src/backend'; +import {DataSource, DataSourceQuery, EmplaceDetails, Server} from '../../src/backend'; import {Client} from '../../src/client'; 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'; +import {addResourceRecipe, ResourceItemFetchedResponse} from '../../src/recipes/resource'; describe('default', () => { let theClient: Client; let theServer: Server; let theRawEndpoint: Endpoint; let theOperation: Operation; + let dataSource: Record; + + beforeAll(() => { + dataSource = { + create: vi.fn(async (data) => data), + getById: vi.fn(async () => ({})), + delete: vi.fn(), + emplace: vi.fn(async () => [{}, { isCreated: false }]), + getMultiple: vi.fn(async () => []), + getSingle: vi.fn(async () => ({})), + getTotalCount: vi.fn(async () => 1), + newId: vi.fn(async () => 1), + patch: vi.fn(async (id, data) => ({ ...data, id })), + initialize: vi.fn(async () => {}), + }; + }); + + afterAll(() => { + dataSource.getById.mockReset(); + }); beforeAll(async () => { const theRawApp = app({ @@ -34,8 +55,8 @@ describe('default', () => { operations, backend: theBackend, } = composeRecipes([ - addResourceRecipe({ endpointName: 'users' }), - addResourceRecipe({ endpointName: 'posts' }) + addResourceRecipe({ endpointName: 'users', dataSource, }), + addResourceRecipe({ endpointName: 'posts', dataSource, }) ])({ app: theRawApp, }); @@ -78,9 +99,28 @@ describe('default', () => { foo: 'bar', })); - const response = YesResponse.fromFetchResponse(responseRaw); + const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); + + expect(response).toHaveProperty('statusCode', 200); + expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); + }); + + it('works for items', async () => { + // 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 responseRaw = await theClient + .at(theRawEndpoint, { resourceId: 3 }) + .makeRequest(theOperation, new URLSearchParams({ + foo: 'bar', + })); + + const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); - expect(response).toHaveProperty('statusCode', 204); - expect(response).toHaveProperty('statusMessage', 'Yes'); + expect(response).toHaveProperty('statusCode', 200); + expect(response).toHaveProperty('statusMessage', 'Resource Item Fetched'); }); }); diff --git a/packages/core/test/index.test.ts b/packages/core/test/index.test.ts index b3c896d..ddf80b5 100644 --- a/packages/core/test/index.test.ts +++ b/packages/core/test/index.test.ts @@ -2,7 +2,6 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; import { App, app, - DataSource, Endpoint, endpoint, Operation, @@ -10,7 +9,7 @@ import { statusCodes, validation as v, } from '../src/common'; -import {Backend, backend, Server} from '../src/backend'; +import {Backend, backend, DataSource, Server} from '../src/backend'; import {Client} from '../src/client'; import {server} from '../src/extenders/http/backend'; import {client} from '../src/extenders/http/client';