diff --git a/packages/core/src/backend/common.ts b/packages/core/src/backend/common.ts index 85dd1e4..9dea619 100644 --- a/packages/core/src/backend/common.ts +++ b/packages/core/src/backend/common.ts @@ -1,53 +1,45 @@ -import {App as BaseApp, AppOperations, Endpoint} from '../common'; +import {App as BaseApp, AppOperations, BaseAppState, Endpoint} from '../common'; + +export interface Response {} interface BackendParams { app: App; } -type AppOperationArgs = ( - App extends BaseApp - ? ( - S extends Record - ? S[Operation][number] - : never - ) - : never - ); - -interface ImplementationFunctionParams = AppOperations> { +export interface ImplementationContext { endpoint: Endpoint; - params: unknown; - arg?: AppOperationArgs; + params: Record; query?: URLSearchParams; } -type ImplementationFunction = AppOperations> = (params: ImplementationFunctionParams) => void; +type ImplementationFunction = (params: ImplementationContext) => Promise; export interface Backend { app: App; - implementations: Map>; - implementOperation>(operation: Operation, implementation: ImplementationFunction): this; + implementations: Map; + implementOperation>( + operation: Operation, implementation: ImplementationFunction): this; } class BackendInstance implements Backend { readonly app: App; - readonly implementations: Map>; + readonly implementations: Map; constructor(params: BackendParams) { this.app = params.app; - this.implementations = new Map>(); + this.implementations = new Map(); } implementOperation>( operation: Operation, - implementation: ImplementationFunction + implementation: ImplementationFunction ) { this.implementations.set(operation, implementation); return this; } } -export const backend = (params: BackendParams): Backend => { +export const backend = , AppName extends string, State extends BaseAppState = BaseAppState>(params: BackendParams): Backend => { return new BackendInstance(params); }; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 3b30785..fd1c211 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -5,9 +5,12 @@ export interface ClientParams { fetch?: typeof fetch; } -export interface Client { +export interface ClientConnection {} + +export interface Client { app: App; - connect(params: ServiceParams): this; + connect(params: ServiceParams): Promise; + disconnect(connection?: Connection): Promise; at(endpoint: TheEndpoint, params?: Record, unknown>): this; - makeRequest(operation: Operation): ReturnType; + makeRequest(operation: Operation, query?: URLSearchParams): ReturnType; } diff --git a/packages/core/src/common/app.ts b/packages/core/src/common/app.ts index 72475ce..7359c1c 100644 --- a/packages/core/src/common/app.ts +++ b/packages/core/src/common/app.ts @@ -7,17 +7,18 @@ export interface BaseAppState { } export type AppOperations = ( - T extends App + T extends App ? R extends BaseAppState - ? keyof R['operations'] - : string - : string + ? R['operations'] extends [] + ? R['operations'] extends readonly string[] + ? R['operations'][number] + : string + : string + : never + : never ); -export interface App; -}> { +export interface App { name: AppName; operations: Set; endpoints: Set; @@ -28,7 +29,11 @@ export interface App ? [...AppState['operations'], NewOperation['name']] : [NewOperation['name']] } >; - endpoint(newEndpoint: EndpointOperations extends AppOperations ? NewEndpoint : never): App< + endpoint( + newEndpoint: EndpointOperations extends AppOperations + ? NewEndpoint + : never + ): App< AppName, { endpoints: AppState['endpoints'] extends Array ? [...AppState['endpoints'], NewEndpoint] : [NewEndpoint], @@ -65,6 +70,9 @@ class AppInstance implemen } } -export const app = (params: Params): App => { +export const app = (params: Params): App => { return new AppInstance(params); }; diff --git a/packages/core/src/common/endpoint.ts b/packages/core/src/common/endpoint.ts index 8f50739..26bca88 100644 --- a/packages/core/src/common/endpoint.ts +++ b/packages/core/src/common/endpoint.ts @@ -21,7 +21,8 @@ export const serializeEndpointQueue = (endpointQueue: EndpointQueue) => { }; export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set) => { - const fragments = urlWithoutBase.split('/').filter((s) => s.trim().length > 0); + const [urlWithoutQueryParams] = urlWithoutBase.split('?'); + const fragments = urlWithoutQueryParams.split('/').filter((s) => s.trim().length > 0); const endpointsArray = Array.from(endpoints); return fragments.reduce( @@ -79,7 +80,11 @@ interface BaseEndpointState { type OpValueType = undefined | boolean | Record | readonly string[]; -export interface Endpoint { +export interface Endpoint< + Name extends string = string, + Schema extends v.BaseSchema = v.BaseSchema, + State extends BaseEndpointState = BaseEndpointState +> { name: Name; schema: Schema; params: Set; @@ -166,6 +171,12 @@ export const endpoint = (params: Params): Endpoin }; -export type EndpointOperations = T extends Endpoint ? ( - R extends { operations: Record } ? R['operations'][number] : never +export type EndpointOperations = T extends Endpoint ? ( + R extends BaseEndpointState + ? R['operations'] extends [] + ? R['operations'] extends readonly string[] + ? R['operations'][number] + : string + : string + : never ) : never; diff --git a/packages/core/src/extenders/http/backend.ts b/packages/core/src/extenders/http/backend/core.ts similarity index 75% rename from packages/core/src/extenders/http/backend.ts rename to packages/core/src/extenders/http/backend/core.ts index b68b90e..dfa7ac4 100644 --- a/packages/core/src/extenders/http/backend.ts +++ b/packages/core/src/extenders/http/backend/core.ts @@ -1,18 +1,22 @@ -import {parseToEndpointQueue, ServiceParams} from '../../common'; +import {parseToEndpointQueue, ServiceParams} from '../../../common'; import { Backend as BaseBackend, Server, ServerRequest, ServerResponse, ServerParams, -} from '../../backend'; +} from '../../../backend'; import http from 'http'; import { constants } from 'http2'; -declare module '../../backend/server' { +declare module '../../../backend' { interface ServerRequest extends http.IncomingMessage {} interface ServerResponse extends http.ServerResponse {} + + interface ImplementationContext { + res: ServerResponse; + } } class ServerInstance implements Server { @@ -23,7 +27,7 @@ class ServerInstance implements Server { this.backend = params.backend; } - private readonly requestListener = (req: ServerRequest, res: ServerResponse) => { + private readonly requestListener = async (req: ServerRequest, res: ServerResponse) => { // const endpoints = this.backend.app.endpoints; if (typeof req.method === 'undefined') { res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {}); @@ -75,14 +79,25 @@ class ServerInstance implements Server { return; } - implementation({ + const [, search] = req.url.split('?'); + + const responseSpec = await implementation({ endpoint, - params: endpointParams + params: endpointParams ?? {}, + query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, + res, }); - res.writeHead(constants.HTTP_STATUS_OK, {}); - res.statusMessage = 'Yes'; - res.end(); + if (typeof responseSpec === 'undefined') { + res.writeHead(constants.HTTP_STATUS_UNPROCESSABLE_ENTITY, {}); + res.end(); + return; + } + + const finalRes = responseSpec.res ?? res; + finalRes.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code + finalRes.writeHead(responseSpec.statusCode, {}); + finalRes.end(); }; serve(params: ServiceParams) { diff --git a/packages/core/src/extenders/http/backend/index.ts b/packages/core/src/extenders/http/backend/index.ts new file mode 100644 index 0000000..9b080f4 --- /dev/null +++ b/packages/core/src/extenders/http/backend/index.ts @@ -0,0 +1,2 @@ +export * from './core'; +export * from './response'; diff --git a/packages/core/src/extenders/http/response.ts b/packages/core/src/extenders/http/backend/response.ts similarity index 87% rename from packages/core/src/extenders/http/response.ts rename to packages/core/src/extenders/http/backend/response.ts index f53823a..37e3c13 100644 --- a/packages/core/src/extenders/http/response.ts +++ b/packages/core/src/extenders/http/backend/response.ts @@ -1,11 +1,14 @@ -import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; +import {Response} from '../../../backend'; +import {ErrorStatusCode, isErrorStatusCode, StatusCode} from '../status-codes'; import http from 'http'; -export interface Response { - statusCode: number; - statusMessage: string; - body?: Buffer; - res?: http.ServerResponse; +declare module '../../../backend' { + interface Response { + statusCode: number; + statusMessage: string; + body?: Buffer; + res?: http.ServerResponse; + } } interface ErrorResponse extends Error, Response {} diff --git a/packages/core/src/extenders/http/client.ts b/packages/core/src/extenders/http/client.ts index 9887d82..d052d1e 100644 --- a/packages/core/src/extenders/http/client.ts +++ b/packages/core/src/extenders/http/client.ts @@ -6,7 +6,15 @@ import { Operation, serializeEndpointQueue, ServiceParams, } from '../../common'; -import {Client, ClientParams} from '../../client'; +import {Client, ClientParams, ClientConnection} from '../../client'; + +declare module '../../client' { + interface ClientConnection { + host: string; + port: number; + basePath: string; + } +} class ClientInstance implements Client { readonly app: App; @@ -19,9 +27,20 @@ class ClientInstance implements Client { this.fetchFn = params.fetch ?? fetch; } - connect(params: ServiceParams) { - this.connection = params; - return this; + async connect(params: ServiceParams): Promise { + const connection = { + host: params.host ?? '0.0.0.0', + port: params.port ?? 80, + basePath: params.basePath ?? '', + }; + + this.connection = connection; + + return connection; + } + + async disconnect() { + // noop } at(endpoint: TheEndpoint, params?: Record, unknown>) { @@ -31,7 +50,7 @@ class ClientInstance implements Client { return this; } - makeRequest(operation: Operation) { + makeRequest(operation: Operation, query?: URLSearchParams) { const baseUrlFragments = [ this.connection?.host ?? '0.0.0.0' ]; @@ -43,18 +62,23 @@ class ClientInstance implements Client { const scheme = 'http'; // todo need a way to decode url back to endpoint queue - const url = serializeEndpointQueue(this.endpointQueue); + const urlString = serializeEndpointQueue(this.endpointQueue); this.endpointQueue = []; + + const url = new URL( + this.connection?.basePath ? `${this.connection.basePath}${urlString}` : urlString, + `${scheme}://${baseUrlFragments.join(':')}` + ); + if (typeof query !== 'undefined') { + url.search = query.toString(); + } const rawEffectiveMethod = (operation.method ?? 'GET').toUpperCase(); const finalEffectiveMethod = (AVAILABLE_EXTENSION_METHODS as unknown as string[]).includes(rawEffectiveMethod) ? 'POST' as const : rawEffectiveMethod; return this.fetchFn( - new URL( - this.connection?.basePath ? `${this.connection.basePath}${url}` : url, - `${scheme}://${baseUrlFragments.join(':')}` - ), + url, { method: finalEffectiveMethod, }, diff --git a/packages/core/test/http/default.test.ts b/packages/core/test/http/default.test.ts new file mode 100644 index 0000000..1fff887 --- /dev/null +++ b/packages/core/test/http/default.test.ts @@ -0,0 +1,94 @@ +import { + describe, + beforeAll, + afterAll, + it, + expect, +} from 'vitest'; + +import {app, endpoint, Endpoint, operation, Operation, validation as v} from '../../src/common'; +import {Server, backend} from '../../src/backend'; +import {Client} from '../../src/client'; +import {server, HttpResponse} from '../../src/extenders/http/backend'; +import {client} from '../../src/extenders/http/client'; + +describe('default', () => { + let theClient: Client; + let theServer: Server; + let theEndpoint: 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({ + name: 'default' as const, + }) + .operation(theOperation) + .endpoint(theEndpoint); + + const theBackend = backend({ + app: theApp, + }); + + theBackend.implementOperation('fetch', async (ctx) => { + class YesResponse extends HttpResponse(204) {} + + return new YesResponse({ + statusMessage: 'Yes', + }, { + res: ctx.res, + }); + }); + + theServer = server({ + backend: theBackend, + }); + + const connectionParams = { + port: 3001, + }; + + await theServer.serve(connectionParams); + + theClient = client({ + app: theApp, + }); + + await theClient.connect(connectionParams); + }); + + afterAll(async () => { + await theClient.disconnect(); + await theServer.close(); + }); + + 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) + .makeRequest(theOperation, new URLSearchParams({ + foo: 'bar', + })); + + expect(response).toHaveProperty('status', 204); + expect(response).toHaveProperty('statusText', 'Yes'); + }); +}); diff --git a/packages/core/test/index.test.ts b/packages/core/test/index.test.ts index f5baade..80d7869 100644 --- a/packages/core/test/index.test.ts +++ b/packages/core/test/index.test.ts @@ -1,8 +1,18 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {App, app, DataSource, Endpoint, endpoint, Operation, operation, validation as v} from '../src/common'; -import {server} from '../src/extenders/http/backend'; +import { + App, + app, + AppOperations, + DataSource, + Endpoint, + endpoint, EndpointOperations, + 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 {client} from '../src/extenders/http/client'; const op = operation({ @@ -59,7 +69,7 @@ describe('app', () => { // of operations. // // recipes should have a backend and client counterpart. - theBackend.implementOperation('fetch', (params) => { + theBackend.implementOperation('fetch', async (ctx) => { // noop }); @@ -75,12 +85,14 @@ describe('app', () => { theClient = client({ app: theApp - }) - .connect(connectionParams); + }); + + await theClient.connect(connectionParams); }); - afterAll(() => { - theServer.close(); + afterAll(async () => { + await theClient.disconnect(); + await theServer.close(); }); it('works', async () => { @@ -88,7 +100,7 @@ describe('app', () => { .at(theEndpoint, { resourceId: 3 }) .makeRequest(theOperation); - expect(response).toHaveProperty('status', 200); + expect(response).toHaveProperty('status', 422); }); });