@@ -1,7 +1,9 @@ | |||
import {App as BaseApp, AppOperations, BaseAppState, Endpoint, Response} from '../common'; | |||
import {DataSource} from './data-source'; | |||
interface BackendParams<App extends BaseApp> { | |||
app: App; | |||
dataSource?: DataSource; | |||
} | |||
export interface ImplementationContext { | |||
@@ -14,6 +16,7 @@ type ImplementationFunction = (params: ImplementationContext) => Promise<Respons | |||
export interface Backend<App extends BaseApp = BaseApp> { | |||
app: App; | |||
dataSource?: DataSource; | |||
implementations: Map<string, ImplementationFunction>; | |||
implementOperation<Operation extends AppOperations<App>>( | |||
operation: Operation, implementation: ImplementationFunction): this; | |||
@@ -21,10 +24,12 @@ export interface Backend<App extends BaseApp = BaseApp> { | |||
class BackendInstance<App extends BaseApp> implements Backend<App> { | |||
readonly app: App; | |||
readonly dataSource?: DataSource; | |||
readonly implementations: Map<string, ImplementationFunction>; | |||
constructor(params: BackendParams<App>) { | |||
this.app = params.app; | |||
this.dataSource = params.dataSource; | |||
this.implementations = new Map<string, ImplementationFunction>(); | |||
} | |||
@@ -0,0 +1,37 @@ | |||
import {QueryAndGrouping, validation as v} from '../common'; | |||
export interface EmplaceDetails { | |||
isCreated: boolean; | |||
} | |||
export type DataSourceQuery = QueryAndGrouping; | |||
export interface DataSource< | |||
ItemData extends object = object, | |||
ID extends unknown = unknown, | |||
Query extends DataSourceQuery = DataSourceQuery, | |||
Emplace extends EmplaceDetails = EmplaceDetails, | |||
DeleteResult = unknown, | |||
> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Query): Promise<number>; | |||
getMultiple(query?: Query): Promise<ItemData[]>; | |||
getById(id: ID): Promise<ItemData | null>; | |||
getSingle?(query?: Query): Promise<ItemData | null>; | |||
create(data: ItemData): Promise<ItemData>; | |||
delete(id: ID): Promise<DeleteResult>; | |||
emplace(id: ID, data: ItemData): Promise<[ItemData, Emplace]>; | |||
patch(id: ID, data: Partial<ItemData>): Promise<ItemData | null>; | |||
newId(): Promise<ID>; | |||
} | |||
export interface ResourceIdConfig<IdSchema extends v.BaseSchema> { | |||
generationStrategy: GenerationStrategy; | |||
serialize: (id: unknown) => string; | |||
deserialize: (id: string) => v.Output<IdSchema>; | |||
schema: IdSchema; | |||
} | |||
export interface GenerationStrategy { | |||
(dataSource: DataSource, ...args: unknown[]): Promise<string | number | unknown>; | |||
} |
@@ -1,2 +1,3 @@ | |||
export * from './common'; | |||
export * from './data-source'; | |||
export * from './server'; |
@@ -1,2 +0,0 @@ | |||
export interface DataSource {} |
@@ -1,4 +1,4 @@ | |||
import {DataSource} from './data-source'; | |||
import {DataSource} from '../backend/data-source'; | |||
import {validation as v} from '.'; | |||
export type EndpointQueue = [Endpoint, Record<string, unknown> | undefined][]; | |||
@@ -87,6 +87,7 @@ export interface Endpoint< | |||
State extends BaseEndpointState = BaseEndpointState | |||
> { | |||
name: Name; | |||
dataSource?: DataSource; | |||
schema: Schema; | |||
params: Set<string>; | |||
operations: Set<string>; | |||
@@ -126,6 +127,7 @@ class EndpointInstance< | |||
State extends BaseEndpointState | |||
> implements Endpoint<Params['name'], Params['schema'], State> { | |||
readonly name: Params['name']; | |||
readonly dataSource: Params['dataSource']; | |||
readonly operations: Set<string>; | |||
readonly params: Set<string>; | |||
readonly schema: Params['schema']; | |||
@@ -135,6 +137,7 @@ class EndpointInstance< | |||
this.schema = params.schema; | |||
this.operations = new Set<string>(); | |||
this.params = new Set<string>(); | |||
this.dataSource = params.dataSource; | |||
} | |||
can<OpName extends string = string, OpValue extends OpValueType = OpValueType>( | |||
@@ -1,10 +1,10 @@ | |||
export * from './app'; | |||
export * from './charset'; | |||
export * from './data-source'; | |||
export * from './endpoint'; | |||
export * from './language'; | |||
export * from './media-type'; | |||
export * from './operation'; | |||
export * from './queries'; | |||
export * from './response'; | |||
export * from './service'; | |||
export * as statusCodes from './status-codes'; | |||
@@ -0,0 +1,53 @@ | |||
import {MediaType} from '../media-type'; | |||
const OPERATORS = [ | |||
'=', | |||
'!=', | |||
'>=', | |||
'<=', | |||
'>', | |||
'<', | |||
'LIKE', | |||
'ILIKE', | |||
'REGEXP', | |||
] as const; | |||
type QueryOperator = typeof OPERATORS[number]; | |||
type QueryExpressionValue = string | number | boolean; | |||
interface QueryOperatorExpression { | |||
lhs: string; | |||
operator: QueryOperator; | |||
rhs: QueryExpressionValue | RegExp; | |||
} | |||
interface QueryFunctionExpression { | |||
name: string; | |||
args: QueryExpressionValue[]; | |||
} | |||
export type QueryAnyExpression = QueryOperatorExpression | QueryFunctionExpression; | |||
export interface QueryOrGrouping { | |||
type: 'or'; | |||
expressions: QueryAnyExpression[]; | |||
} | |||
export interface QueryAndGrouping { | |||
type: 'and'; | |||
expressions: QueryOrGrouping[]; | |||
} | |||
export type Query = QueryAndGrouping; | |||
export interface QueryMediaType< | |||
Name extends string = string, | |||
SerializeOptions extends {} = {}, | |||
DeserializeOptions extends {} = {} | |||
> extends MediaType< | |||
Name, | |||
QueryAndGrouping, | |||
SerializeOptions, | |||
DeserializeOptions | |||
> {} |
@@ -0,0 +1 @@ | |||
export * from './common'; |
@@ -1,4 +1,4 @@ | |||
import {Backend} from '../backend'; | |||
import {Backend, DataSource} from '../backend'; | |||
import {Operation} from './operation'; | |||
import {App} from './app'; | |||
import {Endpoint} from './endpoint'; | |||
@@ -6,6 +6,7 @@ import {Endpoint} from './endpoint'; | |||
export interface RecipeState<A extends App = App> { | |||
app: A; | |||
backend?: Backend<A>; | |||
dataSource?: DataSource; | |||
operations?: Record<string, Operation>; | |||
endpoints?: Record<string, Endpoint>; | |||
} | |||
@@ -2,13 +2,13 @@ import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; | |||
type FetchResponse = Awaited<ReturnType<typeof fetch>>; | |||
export interface Response { | |||
export interface Response<B extends unknown = unknown> { | |||
statusCode: number; | |||
statusMessage: string; | |||
body?: Buffer; | |||
body?: B; | |||
} | |||
export interface ErrorResponse extends Error, Response {} | |||
export interface ErrorResponse<B extends unknown = unknown> extends Error, Response<B> {} | |||
export interface HttpResponseConstructor<R extends Response> { | |||
new (...args: any[]): R; | |||
@@ -20,24 +20,25 @@ export interface HttpResponseErrorConstructor<R extends Response> extends HttpRe | |||
} | |||
export interface HttpSuccessResponseConstructor<R extends Response> extends HttpResponseConstructor<R> { | |||
new (response: Partial<Omit<Response, 'statusCode' | 'res'>>): R; | |||
new (response: Partial<Omit<Response, 'statusCode'>>): R; | |||
} | |||
export interface HttpErrorOptions extends ErrorOptions { | |||
body?: Response['body']; | |||
export interface HttpErrorOptions<B extends unknown = unknown> extends ErrorOptions { | |||
body?: B; | |||
} | |||
export const HttpResponse = < | |||
B extends unknown = unknown, | |||
T extends StatusCode = StatusCode, | |||
R extends Response = T extends ErrorStatusCode ? ErrorResponse : Response, | |||
R extends Response = T extends ErrorStatusCode ? ErrorResponse<B> : Response<B>, | |||
>(statusCode: T): T extends ErrorStatusCode ? HttpResponseErrorConstructor<R> : HttpSuccessResponseConstructor<R> => { | |||
if (isErrorStatusCode(statusCode)) { | |||
return class HttpErrorResponse extends Error implements ErrorResponse { | |||
return class HttpErrorResponse extends Error implements ErrorResponse<B> { | |||
readonly statusMessage: string; | |||
readonly statusCode: T; | |||
readonly body?: Buffer; | |||
readonly body?: B; | |||
constructor(message?: string, options?: HttpErrorOptions) { | |||
constructor(message?: string, options?: HttpErrorOptions<B>) { | |||
super(message, options); | |||
this.name = this.statusMessage = message ?? ''; | |||
this.statusCode = statusCode; | |||
@@ -47,11 +48,11 @@ export const HttpResponse = < | |||
} as unknown as HttpResponseErrorConstructor<R>; | |||
} | |||
return class HttpSuccessResponse implements Response { | |||
return class HttpSuccessResponse implements Response<B> { | |||
readonly statusMessage: string; | |||
readonly statusCode: T; | |||
readonly body?: Buffer; | |||
constructor(params: Partial<Omit<Response, 'statusCode'>>) { | |||
readonly body?: B; | |||
constructor(params: Partial<Omit<Response<B>, 'statusCode'>>) { | |||
this.statusCode = statusCode; | |||
this.statusMessage = params.statusMessage ?? ''; | |||
this.body = params.body; | |||
@@ -1,18 +1,18 @@ | |||
import {parseToEndpointQueue, ServiceParams} from '../../../common'; | |||
import {ErrorResponse, parseToEndpointQueue, ServiceParams} from '../../../common'; | |||
import { | |||
Backend as BaseBackend, | |||
Server, | |||
ServerRequest, | |||
ServerResponse, | |||
ServerRequestContext, | |||
ServerResponseContext, | |||
ServerParams, | |||
} from '../../../backend'; | |||
import http from 'http'; | |||
import { statusCodes } from '../../../common'; | |||
declare module '../../../backend' { | |||
interface ServerRequest extends http.IncomingMessage {} | |||
interface ServerRequestContext extends http.IncomingMessage {} | |||
interface ServerResponse extends http.ServerResponse {} | |||
interface ServerResponseContext extends http.ServerResponse {} | |||
} | |||
class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
@@ -23,7 +23,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
this.backend = params.backend; | |||
} | |||
private readonly requestListener = async (req: ServerRequest, res: ServerResponse) => { | |||
private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => { | |||
// const endpoints = this.backend.app.endpoints; | |||
if (typeof req.method === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); | |||
@@ -81,21 +81,31 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
// 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, | |||
}); | |||
try { | |||
const responseSpec = await implementation({ | |||
endpoint, | |||
params: endpointParams ?? {}, | |||
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | |||
}); | |||
if (typeof responseSpec === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {}); | |||
res.end(); | |||
return; | |||
} | |||
if (typeof responseSpec === 'undefined') { | |||
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {}); | |||
const bodyToSerialize = responseSpec.body; | |||
console.log(bodyToSerialize); | |||
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | |||
res.writeHead(responseSpec.statusCode, {}); | |||
res.end(); | |||
} catch (errorResponseSpecRaw) { | |||
const responseSpec = errorResponseSpecRaw as ErrorResponse; | |||
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | |||
res.writeHead(responseSpec.statusCode, {}); | |||
res.end(); | |||
return; | |||
} | |||
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | |||
res.writeHead(responseSpec.statusCode, {}); | |||
res.end(); | |||
}; | |||
serve(params: ServiceParams) { | |||
@@ -1,52 +0,0 @@ | |||
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) {} |
@@ -0,0 +1,84 @@ | |||
import {Recipe} from '../../common/recipe'; | |||
import {endpoint, operation, validation as v} from '../../common'; | |||
import {backend, DataSource} from '../../backend'; | |||
import { | |||
DataSourceNotFoundResponseError, | |||
ItemNotFoundReponseError, | |||
ResourceCollectionFetchedResponse, | |||
ResourceItemFetchedResponse, | |||
} from './response'; | |||
interface AddResourceRecipeParams { | |||
endpointName: string; | |||
dataSource?: DataSource; | |||
} | |||
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, | |||
dataSource: params.dataSource, | |||
}); | |||
theBackend | |||
.implementOperation('fetch', async (ctx) => { | |||
const dataSource: DataSource = ctx.endpoint.dataSource ?? theBackend.dataSource ?? {} as DataSource; | |||
// need to genericise the response here so we don't depend on the HTTP responses. | |||
const { resourceId } = ctx.params; | |||
const { getById, getMultiple } = dataSource; | |||
if (typeof resourceId === 'undefined') { | |||
if (typeof getMultiple === 'undefined') { | |||
throw new DataSourceNotFoundResponseError(); | |||
} | |||
// TODO add query here | |||
const items = await getMultiple(); | |||
return new ResourceCollectionFetchedResponse({ | |||
statusMessage: 'Resource Collection Fetched', | |||
body: items, | |||
}); | |||
} | |||
if (typeof getById === 'undefined') { | |||
throw new DataSourceNotFoundResponseError(); | |||
} | |||
const item = await getById(resourceId); | |||
if (!item) { | |||
throw new ItemNotFoundReponseError(); | |||
} | |||
return new ResourceItemFetchedResponse({ | |||
statusMessage: 'Resource Item Fetched', | |||
body: item, | |||
}); | |||
}); | |||
return { | |||
operations, | |||
app: enhancedApp, | |||
backend: theBackend, | |||
endpoints: { | |||
[params.endpointName]: theEndpoint, | |||
}, | |||
}; | |||
}; |
@@ -0,0 +1,2 @@ | |||
export * from './core'; | |||
export * from './response'; |
@@ -0,0 +1,9 @@ | |||
import {HttpResponse, statusCodes} from '../../common'; | |||
export class ResourceItemFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} | |||
export class ResourceCollectionFetchedResponse extends HttpResponse(statusCodes.HTTP_STATUS_OK) {} | |||
export class DataSourceNotFoundResponseError extends HttpResponse(statusCodes.HTTP_STATUS_INTERNAL_SERVER_ERROR) {} | |||
export class ItemNotFoundReponseError extends HttpResponse(statusCodes.HTTP_STATUS_NOT_FOUND) {} |
@@ -4,6 +4,7 @@ import { | |||
afterAll, | |||
it, | |||
expect, | |||
vi, Mock, | |||
} from 'vitest'; | |||
import { | |||
@@ -11,18 +12,38 @@ import { | |||
Endpoint, | |||
Operation, | |||
} from '../../src/common'; | |||
import {Server} from '../../src/backend'; | |||
import {DataSource, DataSourceQuery, EmplaceDetails, Server} from '../../src/backend'; | |||
import {Client} from '../../src/client'; | |||
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'; | |||
import {addResourceRecipe, ResourceItemFetchedResponse} from '../../src/recipes/resource'; | |||
describe('default', () => { | |||
let theClient: Client; | |||
let theServer: Server; | |||
let theRawEndpoint: Endpoint; | |||
let theOperation: Operation; | |||
let dataSource: Record<keyof DataSource, Mock>; | |||
beforeAll(() => { | |||
dataSource = { | |||
create: vi.fn(async (data) => data), | |||
getById: vi.fn(async () => ({})), | |||
delete: vi.fn(), | |||
emplace: vi.fn(async () => [{}, { isCreated: false }]), | |||
getMultiple: vi.fn(async () => []), | |||
getSingle: vi.fn(async () => ({})), | |||
getTotalCount: vi.fn(async () => 1), | |||
newId: vi.fn(async () => 1), | |||
patch: vi.fn(async (id, data) => ({ ...data, id })), | |||
initialize: vi.fn(async () => {}), | |||
}; | |||
}); | |||
afterAll(() => { | |||
dataSource.getById.mockReset(); | |||
}); | |||
beforeAll(async () => { | |||
const theRawApp = app({ | |||
@@ -34,8 +55,8 @@ describe('default', () => { | |||
operations, | |||
backend: theBackend, | |||
} = composeRecipes([ | |||
addResourceRecipe({ endpointName: 'users' }), | |||
addResourceRecipe({ endpointName: 'posts' }) | |||
addResourceRecipe({ endpointName: 'users', dataSource, }), | |||
addResourceRecipe({ endpointName: 'posts', dataSource, }) | |||
])({ | |||
app: theRawApp, | |||
}); | |||
@@ -78,9 +99,28 @@ describe('default', () => { | |||
foo: 'bar', | |||
})); | |||
const response = YesResponse.fromFetchResponse(responseRaw); | |||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | |||
expect(response).toHaveProperty('statusCode', 200); | |||
expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); | |||
}); | |||
it('works for items', async () => { | |||
// 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 responseRaw = await theClient | |||
.at(theRawEndpoint, { resourceId: 3 }) | |||
.makeRequest(theOperation, new URLSearchParams({ | |||
foo: 'bar', | |||
})); | |||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | |||
expect(response).toHaveProperty('statusCode', 204); | |||
expect(response).toHaveProperty('statusMessage', 'Yes'); | |||
expect(response).toHaveProperty('statusCode', 200); | |||
expect(response).toHaveProperty('statusMessage', 'Resource Item Fetched'); | |||
}); | |||
}); |
@@ -2,7 +2,6 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; | |||
import { | |||
App, | |||
app, | |||
DataSource, | |||
Endpoint, | |||
endpoint, | |||
Operation, | |||
@@ -10,7 +9,7 @@ import { | |||
statusCodes, | |||
validation as v, | |||
} from '../src/common'; | |||
import {Backend, backend, Server} from '../src/backend'; | |||
import {Backend, backend, DataSource, Server} from '../src/backend'; | |||
import {Client} from '../src/client'; | |||
import {server} from '../src/extenders/http/backend'; | |||
import {client} from '../src/extenders/http/client'; | |||