@@ -1,6 +1,5 @@ | |||||
import {App as BaseApp, AppOperations, BaseAppState, Endpoint} from '../common'; | import {App as BaseApp, AppOperations, BaseAppState, Endpoint} from '../common'; | ||||
export interface Response {} | |||||
import {Response} from '../common/response'; | |||||
interface BackendParams<App extends BaseApp> { | interface BackendParams<App extends BaseApp> { | ||||
app: App; | app: App; | ||||
@@ -34,7 +33,9 @@ class BackendInstance<App extends BaseApp> implements Backend<App> { | |||||
operation: Operation, | operation: Operation, | ||||
implementation: ImplementationFunction | implementation: ImplementationFunction | ||||
) { | ) { | ||||
this.implementations.set(operation, implementation); | |||||
if (!this.implementations.has(operation)) { | |||||
this.implementations.set(operation, implementation); | |||||
} | |||||
return this; | return this; | ||||
} | } | ||||
} | } | ||||
@@ -58,12 +58,27 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen | |||||
} | } | ||||
operation<NewOperation extends Operation>(newOperation: NewOperation) { | 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; | return this; | ||||
} | } | ||||
endpoint<NewEndpoint extends Endpoint = Endpoint>(newEndpoint: NewEndpoint) { | 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); | this.endpoints.add(newEndpoint); | ||||
return this; | return this; | ||||
@@ -38,6 +38,7 @@ export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set<Endp | |||||
]; | ]; | ||||
} | } | ||||
} | } | ||||
if (typeof lastEndpoint === 'undefined') { | if (typeof lastEndpoint === 'undefined') { | ||||
throw new Error(`Invalid URL: ${urlWithoutBase}`); | throw new Error(`Invalid URL: ${urlWithoutBase}`); | ||||
} | } | ||||
@@ -5,5 +5,7 @@ export * from './endpoint'; | |||||
export * from './language'; | export * from './language'; | ||||
export * from './media-type'; | export * from './media-type'; | ||||
export * from './operation'; | export * from './operation'; | ||||
export * from './response'; | |||||
export * from './service'; | export * from './service'; | ||||
export * from './status-codes'; | |||||
export * as validation from 'valibot'; | export * as validation from 'valibot'; |
@@ -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 | |||||
) | |||||
); |
@@ -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>; | |||||
}; |
@@ -13,10 +13,6 @@ 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> { | ||||
@@ -81,11 +77,14 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
const [, search] = req.url.split('?'); | 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({ | const responseSpec = await implementation({ | ||||
endpoint, | endpoint, | ||||
params: endpointParams ?? {}, | params: endpointParams ?? {}, | ||||
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | ||||
res, | |||||
}); | }); | ||||
if (typeof responseSpec === 'undefined') { | if (typeof responseSpec === 'undefined') { | ||||
@@ -94,10 +93,9 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
return; | 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) { | serve(params: ServiceParams) { | ||||
@@ -1,2 +1 @@ | |||||
export * from './core'; | export * from './core'; | ||||
export * from './response'; |
@@ -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>; | |||||
}; |
@@ -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) {} |
@@ -6,50 +6,42 @@ import { | |||||
expect, | expect, | ||||
} from 'vitest'; | } 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 {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 {client} from '../../src/extenders/http/client'; | ||||
import {composeRecipes} from '../../src/common/recipe'; | |||||
import {addResourceRecipe, YesResponse} from '../../src/recipes/resource'; | |||||
describe('default', () => { | describe('default', () => { | ||||
let theClient: Client; | let theClient: Client; | ||||
let theServer: Server; | let theServer: Server; | ||||
let theEndpoint: Endpoint; | |||||
let theRawEndpoint: Endpoint; | |||||
let theOperation: Operation; | let theOperation: Operation; | ||||
beforeAll(async () => { | 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, | name: 'default' as const, | ||||
}) | |||||
.operation(theOperation) | |||||
.endpoint(theEndpoint); | |||||
}); | |||||
const theBackend = backend({ | |||||
const { | |||||
app: theApp, | 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({ | theServer = server({ | ||||
backend: theBackend, | backend: theBackend, | ||||
@@ -74,21 +66,21 @@ describe('default', () => { | |||||
}); | }); | ||||
it('works', async () => { | it('works', async () => { | ||||
theEndpoint.can('fetch'); | |||||
// TODO create wrapper for fetch's Response here | // 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? | // 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 | // 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) | // 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({ | .makeRequest(theOperation, new URLSearchParams({ | ||||
foo: 'bar', | 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,17 +2,16 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; | |||||
import { | import { | ||||
App, | App, | ||||
app, | app, | ||||
AppOperations, | |||||
DataSource, | DataSource, | ||||
Endpoint, | Endpoint, | ||||
endpoint, EndpointOperations, | |||||
endpoint, | |||||
Operation, | Operation, | ||||
operation, | operation, | ||||
validation as v, | validation as v, | ||||
} from '../src/common'; | } 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 {server} from '../src/extenders/http/backend'; | |||||
import {client} from '../src/extenders/http/client'; | import {client} from '../src/extenders/http/client'; | ||||
const op = operation({ | const op = operation({ | ||||