Add basic recipe system for resources.refactor/new-arch
@@ -1,6 +1,5 @@ | |||
import {App as BaseApp, AppOperations, BaseAppState, Endpoint} from '../common'; | |||
export interface Response {} | |||
import {Response} from '../common/response'; | |||
interface BackendParams<App extends BaseApp> { | |||
app: App; | |||
@@ -34,7 +33,9 @@ class BackendInstance<App extends BaseApp> implements Backend<App> { | |||
operation: Operation, | |||
implementation: ImplementationFunction | |||
) { | |||
this.implementations.set(operation, implementation); | |||
if (!this.implementations.has(operation)) { | |||
this.implementations.set(operation, implementation); | |||
} | |||
return this; | |||
} | |||
} | |||
@@ -58,12 +58,27 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen | |||
} | |||
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; | |||
} | |||
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); | |||
return this; | |||
@@ -38,6 +38,7 @@ export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: Set<Endp | |||
]; | |||
} | |||
} | |||
if (typeof lastEndpoint === 'undefined') { | |||
throw new Error(`Invalid URL: ${urlWithoutBase}`); | |||
} | |||
@@ -5,5 +5,7 @@ export * from './endpoint'; | |||
export * from './language'; | |||
export * from './media-type'; | |||
export * from './operation'; | |||
export * from './response'; | |||
export * from './service'; | |||
export * from './status-codes'; | |||
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 ServerResponse extends http.ServerResponse {} | |||
interface ImplementationContext { | |||
res: ServerResponse; | |||
} | |||
} | |||
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('?'); | |||
// TODO get content negotiation params | |||
// TODO add flag on implementation context if CQRS should be enabled | |||
const responseSpec = await implementation({ | |||
endpoint, | |||
params: endpointParams ?? {}, | |||
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | |||
res, | |||
}); | |||
if (typeof responseSpec === 'undefined') { | |||
@@ -94,10 +93,9 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
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) { | |||
@@ -1,2 +1 @@ | |||
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, | |||
} 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 {server, HttpResponse} from '../../src/extenders/http/backend'; | |||
import {server} from '../../src/extenders/http/backend'; | |||
import {client} from '../../src/extenders/http/client'; | |||
import {composeRecipes} from '../../src/common/recipe'; | |||
import {addResourceRecipe, YesResponse} from '../../src/recipes/resource'; | |||
describe('default', () => { | |||
let theClient: Client; | |||
let theServer: Server; | |||
let theEndpoint: Endpoint; | |||
let theRawEndpoint: 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({ | |||
const theRawApp = app({ | |||
name: 'default' as const, | |||
}) | |||
.operation(theOperation) | |||
.endpoint(theEndpoint); | |||
}); | |||
const theBackend = backend({ | |||
const { | |||
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({ | |||
backend: theBackend, | |||
@@ -74,21 +66,21 @@ describe('default', () => { | |||
}); | |||
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) | |||
const responseRaw = await theClient | |||
.at(theRawEndpoint) | |||
.makeRequest(theOperation, new URLSearchParams({ | |||
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 { | |||
App, | |||
app, | |||
AppOperations, | |||
DataSource, | |||
Endpoint, | |||
endpoint, EndpointOperations, | |||
endpoint, | |||
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 {server} from '../src/extenders/http/backend'; | |||
import {client} from '../src/extenders/http/client'; | |||
const op = operation({ | |||