@@ -12,5 +12,5 @@ export interface Client<App extends BaseApp = BaseApp, Connection extends Client | |||||
connect(params: ServiceParams): Promise<Connection>; | connect(params: ServiceParams): Promise<Connection>; | ||||
disconnect(connection?: Connection): Promise<void>; | 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, query?: URLSearchParams): ReturnType<typeof fetch>; | |||||
makeRequest(operation: Operation): ReturnType<typeof fetch>; | |||||
} | } |
@@ -22,6 +22,7 @@ export const serializeEndpointQueue = (endpointQueue: EndpointQueue) => { | |||||
}; | }; | ||||
export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: NamedSet<Endpoint>) => { | export const parseToEndpointQueue = (urlWithoutBase: string, endpoints: NamedSet<Endpoint>) => { | ||||
// why we don't get the url without query params as parameter, because we might need the query params in the future | |||||
const [urlWithoutQueryParams] = urlWithoutBase.split('?'); | const [urlWithoutQueryParams] = urlWithoutBase.split('?'); | ||||
const fragments = urlWithoutQueryParams.split('/').filter((s) => s.trim().length > 0); | const fragments = urlWithoutQueryParams.split('/').filter((s) => s.trim().length > 0); | ||||
@@ -27,16 +27,51 @@ export interface BaseOperationParams< | |||||
export interface Operation<Params extends BaseOperationParams = BaseOperationParams> { | export interface Operation<Params extends BaseOperationParams = BaseOperationParams> { | ||||
name: Params['name']; | name: Params['name']; | ||||
method: Params['method']; | method: Params['method']; | ||||
searchParams?: URLSearchParams; | |||||
search: (...args: ConstructorParameters<typeof URLSearchParams>) => Operation<Params>; | |||||
setBody: (b: unknown) => Operation<Params>; | |||||
body?: unknown; | |||||
} | } | ||||
class OperationInstance<Params extends BaseOperationParams = BaseOperationParams> implements Operation<Params> { | class OperationInstance<Params extends BaseOperationParams = BaseOperationParams> implements Operation<Params> { | ||||
readonly name: Params['name']; | readonly name: Params['name']; | ||||
readonly method: Params['method']; | readonly method: Params['method']; | ||||
theSearchParams?: URLSearchParams; | |||||
// todo add type safety, depend on method when allowing to have body | |||||
theBody?: unknown; | |||||
constructor(params: Params) { | constructor(params: Params) { | ||||
this.name = params.name; | this.name = params.name; | ||||
this.method = params.method ?? 'GET'; | this.method = params.method ?? 'GET'; | ||||
} | } | ||||
search(...args: ConstructorParameters<typeof URLSearchParams>): Operation<Params> { | |||||
this.theSearchParams = new URLSearchParams(...args); | |||||
return this; | |||||
} | |||||
get searchParams() { | |||||
return Object.freeze(this.theSearchParams); | |||||
} | |||||
get body() { | |||||
return Object.freeze(this.theBody); | |||||
} | |||||
setBody(b: unknown): Operation<Params> { | |||||
switch (this.method) { | |||||
case 'PATCH': | |||||
case 'PUT': | |||||
case 'POST': | |||||
case 'QUERY': | |||||
this.theBody = b; | |||||
break; | |||||
default: | |||||
break; | |||||
} | |||||
return this; | |||||
} | |||||
} | } | ||||
export const operation = <Params extends BaseOperationParams = BaseOperationParams>( | export const operation = <Params extends BaseOperationParams = BaseOperationParams>( | ||||
@@ -3,7 +3,7 @@ import {Operation} from './operation'; | |||||
import {App} from './app'; | import {App} from './app'; | ||||
import {Endpoint} from './endpoint'; | import {Endpoint} from './endpoint'; | ||||
export interface RecipeState<A extends App = App> { | |||||
export interface RecipeState<AppName extends string = string, A extends App<AppName> = App<AppName>> { | |||||
app: A; | app: A; | ||||
backend?: Backend<A>; | backend?: Backend<A>; | ||||
dataSource?: DataSource; | dataSource?: DataSource; | ||||
@@ -11,7 +11,7 @@ export interface RecipeState<A extends App = App> { | |||||
endpoints?: Record<string, Endpoint>; | endpoints?: Record<string, Endpoint>; | ||||
} | } | ||||
export type Recipe<A extends App = App, B extends A = A> = (a: RecipeState<A>) => RecipeState<B>; | |||||
export type Recipe<SA extends string = string, A extends App<SA> = App<SA>, B extends A = A> = (a: RecipeState<SA, A>) => RecipeState<SA, B>; | |||||
export const composeRecipes = (recipes: Recipe[]): Recipe => (params) => ( | export const composeRecipes = (recipes: Recipe[]): Recipe => (params) => ( | ||||
recipes.reduce( | recipes.reduce( | ||||
@@ -36,6 +36,8 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
return; | return; | ||||
} | } | ||||
console.log(req.method, req.url); | |||||
const endpoints = parseToEndpointQueue(req.url, this.backend.app.endpoints); | const endpoints = parseToEndpointQueue(req.url, this.backend.app.endpoints); | ||||
const [endpoint, endpointParams] = endpoints.at(-1) ?? []; | const [endpoint, endpointParams] = endpoints.at(-1) ?? []; | ||||
@@ -51,8 +53,8 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
return; | return; | ||||
} | } | ||||
const endpointOperations = Array.from(endpoint?.operations ?? []); | |||||
if (!endpointOperations.includes(foundAppOperation.name)) { | |||||
if (!endpoint?.operations?.has(foundAppOperation.name)) { | |||||
const endpointOperations = Array.from(endpoint?.operations ?? []); | |||||
res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { | res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { | ||||
'Allow': endpointOperations | 'Allow': endpointOperations | ||||
.map((a) => appOperations.find((aa) => aa.name === a)?.method) | .map((a) => appOperations.find((aa) => aa.name === a)?.method) | ||||
@@ -94,6 +96,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
return; | return; | ||||
} | } | ||||
// TODO serialize using content-negotiation params | |||||
const bodyToSerialize = responseSpec.body; | const bodyToSerialize = responseSpec.body; | ||||
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code | ||||
@@ -50,7 +50,7 @@ class ClientInstance<App extends BaseApp> implements Client<App> { | |||||
return this; | return this; | ||||
} | } | ||||
makeRequest(operation: Operation, query?: URLSearchParams) { | |||||
makeRequest(operation: Operation) { | |||||
const baseUrlFragments = [ | const baseUrlFragments = [ | ||||
this.connection?.host ?? '0.0.0.0' | this.connection?.host ?? '0.0.0.0' | ||||
]; | ]; | ||||
@@ -69,18 +69,30 @@ class ClientInstance<App extends BaseApp> implements Client<App> { | |||||
this.connection?.basePath ? `${this.connection.basePath}${urlString}` : urlString, | this.connection?.basePath ? `${this.connection.basePath}${urlString}` : urlString, | ||||
`${scheme}://${baseUrlFragments.join(':')}` | `${scheme}://${baseUrlFragments.join(':')}` | ||||
); | ); | ||||
if (typeof query !== 'undefined') { | |||||
url.search = query.toString(); | |||||
if (typeof operation.searchParams !== 'undefined') { | |||||
url.search = operation.searchParams.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; | ||||
if (typeof operation.body !== 'undefined') { | |||||
return this.fetchFn( | |||||
url, | |||||
{ | |||||
method: finalEffectiveMethod, | |||||
body: operation.body as string, | |||||
// TODO inject headers | |||||
}, | |||||
); | |||||
} | |||||
return this.fetchFn( | return this.fetchFn( | ||||
url, | url, | ||||
{ | { | ||||
method: finalEffectiveMethod, | method: finalEffectiveMethod, | ||||
// TODO inject headers | |||||
}, | }, | ||||
); | ); | ||||
} | } | ||||
@@ -46,10 +46,6 @@ describe('default', () => { | |||||
}); | }); | ||||
beforeAll(async () => { | beforeAll(async () => { | ||||
const theRawApp = app({ | |||||
name: 'default' as const, | |||||
}); | |||||
const { | const { | ||||
app: theApp, | app: theApp, | ||||
operations, | operations, | ||||
@@ -58,7 +54,9 @@ describe('default', () => { | |||||
addResourceRecipe({ endpointName: 'users', dataSource, }), | addResourceRecipe({ endpointName: 'users', dataSource, }), | ||||
addResourceRecipe({ endpointName: 'posts', dataSource, }) | addResourceRecipe({ endpointName: 'posts', dataSource, }) | ||||
])({ | ])({ | ||||
app: theRawApp, | |||||
app: app({ | |||||
name: 'default' as const, | |||||
}), | |||||
}); | }); | ||||
theRawEndpoint = theApp.endpoints.get('users'); | theRawEndpoint = theApp.endpoints.get('users'); | ||||
@@ -95,9 +93,12 @@ describe('default', () => { | |||||
// 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 responseRaw = await theClient | const responseRaw = await theClient | ||||
.at(theRawEndpoint) | .at(theRawEndpoint) | ||||
.makeRequest(theOperation, new URLSearchParams({ | |||||
foo: 'bar', | |||||
})); | |||||
.makeRequest( | |||||
theOperation | |||||
.search({ | |||||
foo: 'bar', | |||||
}) | |||||
); | |||||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | ||||
@@ -114,9 +115,13 @@ describe('default', () => { | |||||
// 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 responseRaw = await theClient | const responseRaw = await theClient | ||||
.at(theRawEndpoint, { resourceId: 3 }) | .at(theRawEndpoint, { resourceId: 3 }) | ||||
.makeRequest(theOperation, new URLSearchParams({ | |||||
foo: 'bar', | |||||
})); | |||||
// TODO how to inject extra data (e.g. headers, body) in the operation (e.g. auth)? | |||||
.makeRequest( | |||||
theOperation | |||||
.search({ | |||||
foo: 'bar', | |||||
}) // allow multiple calls of .search() to add to search params | |||||
); | |||||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | ||||