Browse Source

Implement recipe system

Add basic recipe system for resources.
refactor/new-arch
TheoryOfNekomata 7 months ago
parent
commit
392b842351
13 changed files with 214 additions and 120 deletions
  1. +4
    -3
      packages/core/src/backend/common.ts
  2. +16
    -1
      packages/core/src/common/app.ts
  3. +1
    -0
      packages/core/src/common/endpoint.ts
  4. +2
    -0
      packages/core/src/common/index.ts
  5. +20
    -0
      packages/core/src/common/recipe.ts
  6. +82
    -0
      packages/core/src/common/response.ts
  7. +0
    -0
      packages/core/src/common/status-codes.ts
  8. +7
    -9
      packages/core/src/extenders/http/backend/core.ts
  9. +0
    -1
      packages/core/src/extenders/http/backend/index.ts
  10. +0
    -67
      packages/core/src/extenders/http/backend/response.ts
  11. +52
    -0
      packages/core/src/recipes/resource.ts
  12. +28
    -36
      packages/core/test/http/default.test.ts
  13. +2
    -3
      packages/core/test/index.test.ts

+ 4
- 3
packages/core/src/backend/common.ts View File

@@ -1,6 +1,5 @@
import {App as BaseApp, AppOperations, BaseAppState, Endpoint} from '../common';

export interface Response {}
import {Response} from '../common/response';

interface BackendParams<App extends BaseApp> {
app: App;
@@ -34,7 +33,9 @@ class BackendInstance<App extends BaseApp> implements Backend<App> {
operation: Operation,
implementation: ImplementationFunction
) {
this.implementations.set(operation, implementation);
if (!this.implementations.has(operation)) {
this.implementations.set(operation, implementation);
}
return this;
}
}


+ 16
- 1
packages/core/src/common/app.ts View File

@@ -58,12 +58,27 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen
}

operation<NewOperation extends 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 extends Endpoint = 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;


+ 1
- 0
packages/core/src/common/endpoint.ts View File

@@ -38,6 +38,7 @@ export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set<Endp
];
}
}

if (typeof lastEndpoint === 'undefined') {
throw new Error(`Invalid URL: ${urlWithoutBase}`);
}


+ 2
- 0
packages/core/src/common/index.ts View File

@@ -5,5 +5,7 @@ export * from './endpoint';
export * from './language';
export * from './media-type';
export * from './operation';
export * from './response';
export * from './service';
export * from './status-codes';
export * as validation from 'valibot';

+ 20
- 0
packages/core/src/common/recipe.ts View File

@@ -0,0 +1,20 @@
import {Operation} from './operation';
import {App} from './app';
import {Backend} from '../backend';
import {Endpoint} from './endpoint';

export interface RecipeState<A extends App = App> {
app: A;
backend?: Backend<A>;
operations?: Record<string, Operation>;
endpoints?: Record<string, Endpoint>;
}

export type Recipe<A extends App = App, B extends A = A> = (a: RecipeState<A>) => RecipeState<B>;

export const composeRecipes = (recipes: Recipe[]): Recipe => (params) => (
recipes.reduce(
(rr, r) => r(rr),
params
)
);

+ 82
- 0
packages/core/src/common/response.ts View File

@@ -0,0 +1,82 @@
import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes';

type FetchResponse = Awaited<ReturnType<typeof fetch>>;

export interface Response {
statusCode: number;
statusMessage: string;
body?: Buffer;
}

export interface ErrorResponse extends Error, Response {}

export interface HttpResponseConstructor<R extends Response> {
new (...args: any[]): R;
fromFetchResponse(response: FetchResponse): R;
}

export interface HttpResponseErrorConstructor<R extends Response> extends HttpResponseConstructor<R> {
new (message?: string, options?: ErrorOptions): R;
}

export interface HttpSuccessResponseConstructor<R extends Response> extends HttpResponseConstructor<R> {
new (response: Partial<Omit<Response, 'statusCode' | 'res'>>): 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<R> : HttpSuccessResponseConstructor<R> => {
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<R>;
}

return class HttpSuccessResponse implements Response {
readonly statusMessage: string;
readonly statusCode: T;
readonly body?: Buffer;
constructor(params: Partial<Omit<Response, 'statusCode'>>) {
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<R>;
};

packages/core/src/extenders/http/status-codes.ts → packages/core/src/common/status-codes.ts View File


+ 7
- 9
packages/core/src/extenders/http/backend/core.ts View File

@@ -13,10 +13,6 @@ declare module '../../../backend' {
interface ServerRequest extends http.IncomingMessage {}

interface ServerResponse extends http.ServerResponse {}

interface ImplementationContext {
res: ServerResponse;
}
}

class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
@@ -81,11 +77,14 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {

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<Backend extends BaseBackend> implements Server<Backend> {
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) {


+ 0
- 1
packages/core/src/extenders/http/backend/index.ts View File

@@ -1,2 +1 @@
export * from './core';
export * from './response';

+ 0
- 67
packages/core/src/extenders/http/backend/response.ts View File

@@ -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<R extends Response> {
new (...args: any[]): R;
}

interface HttpResponseErrorConstructor<R extends Response> extends HttpResponseConstructor<R> {
new (message?: string, options?: ErrorOptions): R;
}

interface HttpSuccessResponseConstructor<R extends Response> extends HttpResponseConstructor<R> {
new (response: Partial<Omit<Response, 'statusCode' | 'res'>>, options?: Pick<Response, 'res'>): 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<R> : HttpSuccessResponseConstructor<R> => {
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<R>;
}

return class HttpSuccessResponse implements Response {
readonly statusMessage: string;
readonly statusCode: T;
readonly body?: Buffer;
readonly res?: http.ServerResponse;
constructor(params: Partial<Omit<Response, 'statusCode'>>, options?: Pick<Response, 'res'>) {
this.statusCode = statusCode;
this.statusMessage = params.statusMessage ?? '';
this.body = params.body;
this.res = options?.res;
}
} as unknown as HttpSuccessResponseConstructor<R>;
};

+ 52
- 0
packages/core/src/recipes/resource.ts View File

@@ -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) {}

+ 28
- 36
packages/core/test/http/default.test.ts View File

@@ -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');
});
});

+ 2
- 3
packages/core/test/index.test.ts View File

@@ -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({


Loading…
Cancel
Save