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> {
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;
params: unknown;
arg?: AppOperationArgs<App, Operation>;
params: Record<string, unknown>;
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> {
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> {
readonly app: App;
readonly implementations: Map<string, ImplementationFunction<App>>;
readonly implementations: Map<string, ImplementationFunction>;

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

implementOperation<Operation extends AppOperations<App>>(
operation: Operation,
implementation: ImplementationFunction<App, Operation>
implementation: ImplementationFunction
) {
this.implementations.set(operation, implementation);
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);
};



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

@@ -5,9 +5,12 @@ export interface ClientParams<App extends BaseApp> {
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;
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;
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> = (
T extends App<any, infer R>
T extends App<string, infer R>
? 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;
operations: Set<Operation>;
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']]
}
>;
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,
{
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);
};

+ 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>) => {
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<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;
schema: Schema;
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;

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

+ 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';

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

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

@@ -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<App extends BaseApp> implements Client<App> {
readonly app: App;
@@ -19,9 +27,20 @@ class ClientInstance<App extends BaseApp> implements Client<App> {
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>) {
@@ -31,7 +50,7 @@ class ClientInstance<App extends BaseApp> implements Client<App> {
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<App extends BaseApp> implements Client<App> {

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,
},


+ 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 {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);
});
});



Loading…
Cancel
Save