Allow basic functionality for HTTP servers.refactor/new-arch
@@ -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); | ||||
}; | }; | ||||
@@ -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>; | |||||
} | } |
@@ -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); | ||||
}; | }; |
@@ -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; |
@@ -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) { |
@@ -0,0 +1,2 @@ | |||||
export * from './core'; | |||||
export * from './response'; |
@@ -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 {} |
@@ -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, | ||||
}, | }, | ||||
@@ -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'); | |||||
}); | |||||
}); |
@@ -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); | |||||
}); | }); | ||||
}); | }); | ||||