Browse Source

Test backend implementations

Allow basic functionality for HTTP servers.
refactor/new-arch
TheoryOfNekomata 5 months ago
parent
commit
9786d79b7a
10 changed files with 235 additions and 71 deletions
  1. +13
    -21
      packages/core/src/backend/common.ts
  2. +6
    -3
      packages/core/src/client/index.ts
  3. +18
    -10
      packages/core/src/common/app.ts
  4. +15
    -4
      packages/core/src/common/endpoint.ts
  5. +24
    -9
      packages/core/src/extenders/http/backend/core.ts
  6. +2
    -0
      packages/core/src/extenders/http/backend/index.ts
  7. +9
    -6
      packages/core/src/extenders/http/backend/response.ts
  8. +34
    -10
      packages/core/src/extenders/http/client.ts
  9. +94
    -0
      packages/core/test/http/default.test.ts
  10. +20
    -8
      packages/core/test/index.test.ts

+ 13
- 21
packages/core/src/backend/common.ts View File

@@ -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 extends BaseApp> { interface BackendParams<App extends BaseApp> {
app: App; app: App;
} }


type AppOperationArgs<App extends BaseApp, Operation extends string> = (
App extends BaseApp<string, { endpoints: any; operations: infer S }>
? (
S extends Record<string, readonly string[]>
? S[Operation][number]
: never
)
: never
);

interface ImplementationFunctionParams<App extends BaseApp, Operation extends AppOperations<App> = AppOperations<App>> {
export interface ImplementationContext {
endpoint: Endpoint; endpoint: Endpoint;
params: unknown;
arg?: AppOperationArgs<App, Operation>;
params: Record<string, unknown>;
query?: URLSearchParams; query?: URLSearchParams;
} }


type ImplementationFunction<App extends BaseApp, Operation extends AppOperations<App> = AppOperations<App>> = (params: ImplementationFunctionParams<App, Operation>) => void;
type ImplementationFunction = (params: ImplementationContext) => Promise<Response | void>;


export interface Backend<App extends BaseApp = BaseApp> { export interface Backend<App extends BaseApp = BaseApp> {
app: App; app: App;
implementations: Map<string, ImplementationFunction<App>>;
implementOperation<Operation extends AppOperations<App>>(operation: Operation, implementation: ImplementationFunction<App, Operation>): this;
implementations: Map<string, ImplementationFunction>;
implementOperation<Operation extends AppOperations<App>>(
operation: Operation, implementation: ImplementationFunction): this;
} }


class BackendInstance<App extends BaseApp> implements Backend<App> { class BackendInstance<App extends BaseApp> implements Backend<App> {
readonly app: App; readonly app: App;
readonly implementations: Map<string, ImplementationFunction<App>>;
readonly implementations: Map<string, ImplementationFunction>;


constructor(params: BackendParams<App>) { constructor(params: BackendParams<App>) {
this.app = params.app; this.app = params.app;
this.implementations = new Map<string, ImplementationFunction<App>>();
this.implementations = new Map<string, ImplementationFunction>();
} }


implementOperation<Operation extends AppOperations<App>>( implementOperation<Operation extends AppOperations<App>>(
operation: Operation, operation: Operation,
implementation: ImplementationFunction<App, Operation>
implementation: ImplementationFunction
) { ) {
this.implementations.set(operation, implementation); this.implementations.set(operation, implementation);
return this; return this;
} }
} }


export const backend = <App extends BaseApp>(params: BackendParams<App>): Backend<App> => {
export const backend = <App extends BaseApp<AppName, State>, AppName extends string, State extends BaseAppState = BaseAppState>(params: BackendParams<App>): Backend<App> => {
return new BackendInstance(params); return new BackendInstance(params);
}; };




+ 6
- 3
packages/core/src/client/index.ts View File

@@ -5,9 +5,12 @@ export interface ClientParams<App extends BaseApp> {
fetch?: typeof fetch; fetch?: typeof fetch;
} }


export interface Client<App extends BaseApp = BaseApp> {
export interface ClientConnection {}

export interface Client<App extends BaseApp = BaseApp, Connection extends ClientConnection = ClientConnection> {
app: App; app: App;
connect(params: ServiceParams): this;
connect(params: ServiceParams): Promise<Connection>;
disconnect(connection?: Connection): Promise<void>;
at<TheEndpoint extends Endpoint = Endpoint>(endpoint: TheEndpoint, params?: Record<GetEndpointParams<TheEndpoint>, unknown>): this; at<TheEndpoint extends Endpoint = Endpoint>(endpoint: TheEndpoint, params?: Record<GetEndpointParams<TheEndpoint>, unknown>): this;
makeRequest(operation: Operation): ReturnType<typeof fetch>;
makeRequest(operation: Operation, query?: URLSearchParams): ReturnType<typeof fetch>;
} }

+ 18
- 10
packages/core/src/common/app.ts View File

@@ -7,17 +7,18 @@ export interface BaseAppState {
} }


export type AppOperations<T extends App> = ( export type AppOperations<T extends App> = (
T extends App<any, infer R>
T extends App<string, infer R>
? R extends BaseAppState ? 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<AppName extends string = string, AppState extends BaseAppState = {
endpoints: [];
operations: Record<string, []>;
}> {
export interface App<AppName extends string = string, AppState extends BaseAppState = BaseAppState> {
name: AppName; name: AppName;
operations: Set<Operation>; operations: Set<Operation>;
endpoints: Set<Endpoint>; endpoints: Set<Endpoint>;
@@ -28,7 +29,11 @@ export interface App<AppName extends string = string, AppState extends BaseAppSt
operations: AppState['operations'] extends Array<unknown> ? [...AppState['operations'], NewOperation['name']] : [NewOperation['name']] operations: AppState['operations'] extends Array<unknown> ? [...AppState['operations'], NewOperation['name']] : [NewOperation['name']]
} }
>; >;
endpoint<NewEndpoint extends Endpoint = Endpoint>(newEndpoint: EndpointOperations<NewEndpoint> extends AppOperations<this> ? NewEndpoint : never): App<
endpoint<NewEndpoint extends Endpoint = Endpoint>(
newEndpoint: EndpointOperations<NewEndpoint> extends AppOperations<this>
? NewEndpoint
: never
): App<
AppName, AppName,
{ {
endpoints: AppState['endpoints'] extends Array<unknown> ? [...AppState['endpoints'], NewEndpoint] : [NewEndpoint], endpoints: AppState['endpoints'] extends Array<unknown> ? [...AppState['endpoints'], NewEndpoint] : [NewEndpoint],
@@ -65,6 +70,9 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen
} }
} }


export const app = <Params extends AppParams>(params: Params): App<Params['name']> => {
export const app = <Params extends AppParams>(params: Params): App<Params['name'], {
endpoints: [];
operations: [];
}> => {
return new AppInstance(params); return new AppInstance(params);
}; };

+ 15
- 4
packages/core/src/common/endpoint.ts View File

@@ -21,7 +21,8 @@ export const serializeEndpointQueue = (endpointQueue: EndpointQueue) => {
}; };


export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set<Endpoint>) => { export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set<Endpoint>) => {
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); const endpointsArray = Array.from(endpoints);


return fragments.reduce( return fragments.reduce(
@@ -79,7 +80,11 @@ interface BaseEndpointState {


type OpValueType = undefined | boolean | Record<string, boolean> | readonly string[]; type OpValueType = undefined | boolean | Record<string, boolean> | readonly string[];


export interface Endpoint<Name extends string = string, Schema extends v.BaseSchema = v.BaseSchema, State extends BaseEndpointState = BaseEndpointState> {
export interface Endpoint<
Name extends string = string,
Schema extends v.BaseSchema = v.BaseSchema,
State extends BaseEndpointState = BaseEndpointState
> {
name: Name; name: Name;
schema: Schema; schema: Schema;
params: Set<string>; params: Set<string>;
@@ -166,6 +171,12 @@ export const endpoint = <Params extends EndpointParams>(params: Params): Endpoin
}; };




export type EndpointOperations<T extends Endpoint> = T extends Endpoint<any, infer R> ? (
R extends { operations: Record<number, any> } ? R['operations'][number] : never
export type EndpointOperations<T extends Endpoint> = T extends Endpoint<string, v.BaseSchema, infer R> ? (
R extends BaseEndpointState
? R['operations'] extends []
? R['operations'] extends readonly string[]
? R['operations'][number]
: string
: string
: never
) : never; ) : never;

packages/core/src/extenders/http/backend.ts → packages/core/src/extenders/http/backend/core.ts View File

@@ -1,18 +1,22 @@
import {parseToEndpointQueue, ServiceParams} from '../../common';
import {parseToEndpointQueue, ServiceParams} from '../../../common';
import { import {
Backend as BaseBackend, Backend as BaseBackend,
Server, Server,
ServerRequest, ServerRequest,
ServerResponse, ServerResponse,
ServerParams, ServerParams,
} from '../../backend';
} from '../../../backend';
import http from 'http'; import http from 'http';
import { constants } from 'http2'; import { constants } from 'http2';


declare module '../../backend/server' {
declare module '../../../backend' {
interface ServerRequest extends http.IncomingMessage {} interface ServerRequest extends http.IncomingMessage {}


interface ServerResponse extends http.ServerResponse {} interface ServerResponse extends http.ServerResponse {}

interface ImplementationContext {
res: ServerResponse;
}
} }


class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
@@ -23,7 +27,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
this.backend = params.backend; 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; // const endpoints = this.backend.app.endpoints;
if (typeof req.method === 'undefined') { if (typeof req.method === 'undefined') {
res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {}); res.writeHead(constants.HTTP_STATUS_BAD_REQUEST, {});
@@ -75,14 +79,25 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
return; return;
} }


implementation({
const [, search] = req.url.split('?');

const responseSpec = await implementation({
endpoint, 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) { serve(params: ServiceParams) {

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

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

packages/core/src/extenders/http/response.ts → packages/core/src/extenders/http/backend/response.ts View File

@@ -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'; 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 {} interface ErrorResponse extends Error, Response {}

+ 34
- 10
packages/core/src/extenders/http/client.ts View File

@@ -6,7 +6,15 @@ import {
Operation, serializeEndpointQueue, Operation, serializeEndpointQueue,
ServiceParams, ServiceParams,
} from '../../common'; } 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<App extends BaseApp> implements Client<App> { class ClientInstance<App extends BaseApp> implements Client<App> {
readonly app: App; readonly app: App;
@@ -19,9 +27,20 @@ class ClientInstance<App extends BaseApp> implements Client<App> {
this.fetchFn = params.fetch ?? fetch; this.fetchFn = params.fetch ?? fetch;
} }


connect(params: ServiceParams) {
this.connection = params;
return this;
async connect(params: ServiceParams): Promise<ClientConnection> {
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<TheEndpoint extends Endpoint = Endpoint>(endpoint: TheEndpoint, params?: Record<GetEndpointParams<TheEndpoint>, unknown>) { at<TheEndpoint extends Endpoint = Endpoint>(endpoint: TheEndpoint, params?: Record<GetEndpointParams<TheEndpoint>, unknown>) {
@@ -31,7 +50,7 @@ class ClientInstance<App extends BaseApp> implements Client<App> {
return this; return this;
} }


makeRequest(operation: Operation) {
makeRequest(operation: Operation, query?: URLSearchParams) {
const baseUrlFragments = [ const baseUrlFragments = [
this.connection?.host ?? '0.0.0.0' this.connection?.host ?? '0.0.0.0'
]; ];
@@ -43,18 +62,23 @@ class ClientInstance<App extends BaseApp> implements Client<App> {


const scheme = 'http'; const scheme = 'http';
// todo need a way to decode url back to endpoint queue // todo need a way to decode url back to endpoint queue
const url = serializeEndpointQueue(this.endpointQueue);
const urlString = serializeEndpointQueue(this.endpointQueue);
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 rawEffectiveMethod = (operation.method ?? 'GET').toUpperCase();
const finalEffectiveMethod = (AVAILABLE_EXTENSION_METHODS as unknown as string[]).includes(rawEffectiveMethod) const finalEffectiveMethod = (AVAILABLE_EXTENSION_METHODS as unknown as string[]).includes(rawEffectiveMethod)
? 'POST' as const ? 'POST' as const
: rawEffectiveMethod; : rawEffectiveMethod;


return this.fetchFn( return this.fetchFn(
new URL(
this.connection?.basePath ? `${this.connection.basePath}${url}` : url,
`${scheme}://${baseUrlFragments.join(':')}`
),
url,
{ {
method: finalEffectiveMethod, method: finalEffectiveMethod,
}, },


+ 94
- 0
packages/core/test/http/default.test.ts View File

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

+ 20
- 8
packages/core/test/index.test.ts View File

@@ -1,8 +1,18 @@
import {describe, it, expect, beforeAll, afterAll} from 'vitest'; 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 {Backend, backend, Server} from '../src/backend';
import {Client} from '../src/client'; import {Client} from '../src/client';
import {server} from '../src/extenders/http/backend/core';
import {client} from '../src/extenders/http/client'; import {client} from '../src/extenders/http/client';


const op = operation({ const op = operation({
@@ -59,7 +69,7 @@ describe('app', () => {
// of operations. // of operations.
// //
// recipes should have a backend and client counterpart. // recipes should have a backend and client counterpart.
theBackend.implementOperation('fetch', (params) => {
theBackend.implementOperation('fetch', async (ctx) => {
// noop // noop
}); });


@@ -75,12 +85,14 @@ describe('app', () => {


theClient = client({ theClient = client({
app: theApp app: theApp
})
.connect(connectionParams);
});

await theClient.connect(connectionParams);
}); });


afterAll(() => {
theServer.close();
afterAll(async () => {
await theClient.disconnect();
await theServer.close();
}); });


it('works', async () => { it('works', async () => {
@@ -88,7 +100,7 @@ describe('app', () => {
.at(theEndpoint, { resourceId: 3 }) .at(theEndpoint, { resourceId: 3 })
.makeRequest(theOperation); .makeRequest(theOperation);


expect(response).toHaveProperty('status', 200);
expect(response).toHaveProperty('status', 422);
}); });
}); });




Loading…
Cancel
Save