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