Ensure new endpoints are following correct request/response content negotiation.refactor/new-arch
@@ -72,42 +72,43 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen | |||||
s.method === newOperation.method | s.method === newOperation.method | ||||
)); | )); | ||||
this.languages = new Map<Language['name'], Language>([ | this.languages = new Map<Language['name'], Language>([ | ||||
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | |||||
[FALLBACK_LANGUAGE.name.toLowerCase(), FALLBACK_LANGUAGE], | |||||
]); | ]); | ||||
this.mediaTypes = new Map<MediaType['name'], MediaType>([ | this.mediaTypes = new Map<MediaType['name'], MediaType>([ | ||||
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], | |||||
[FALLBACK_MEDIA_TYPE.name.toLowerCase(), FALLBACK_MEDIA_TYPE], | |||||
]); | ]); | ||||
this.charsets = new Map<Charset['name'], Charset>([ | this.charsets = new Map<Charset['name'], Charset>([ | ||||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | |||||
[FALLBACK_CHARSET.name.toLowerCase(), FALLBACK_CHARSET], | |||||
]); | ]); | ||||
} | } | ||||
language(language: Language): this { | language(language: Language): this { | ||||
this.languages.set(language.name, language); | |||||
this.languages.set(language.name.toLowerCase(), language); | |||||
return this; | return this; | ||||
} | } | ||||
mediaType(mediaType: MediaType): this { | mediaType(mediaType: MediaType): this { | ||||
this.mediaTypes.set(mediaType.name, mediaType); | |||||
this.mediaTypes.set(mediaType.name.toLowerCase(), mediaType); | |||||
return this; | return this; | ||||
} | } | ||||
charset(charset: Charset): this { | charset(charset: Charset): this { | ||||
this.charsets.set(charset.name, charset); | |||||
this.charsets.set(charset.name.toLowerCase(), charset); | |||||
return this; | return this; | ||||
} | } | ||||
operation<NewOperation extends Operation>(newOperation: NewOperation) { | operation<NewOperation extends Operation>(newOperation: NewOperation) { | ||||
this.operations.set(newOperation.name, newOperation); | |||||
this.operations.set(newOperation.name.toLowerCase(), newOperation); | |||||
return this; | return this; | ||||
} | } | ||||
endpoint<NewEndpoint extends Endpoint = Endpoint>(newEndpoint: NewEndpoint) { | endpoint<NewEndpoint extends Endpoint = Endpoint>(newEndpoint: NewEndpoint) { | ||||
if (this.endpoints.has(newEndpoint.name)) { | |||||
throw new Error(`Cannot add duplicate endpoint with name: ${newEndpoint.name}`); | |||||
const nameNormalized = newEndpoint.name.toLowerCase() | |||||
if (this.endpoints.has(nameNormalized)) { | |||||
throw new Error(`Cannot add duplicate endpoint with name: ${nameNormalized}`); | |||||
} | } | ||||
this.endpoints.set(newEndpoint.name, newEndpoint); | |||||
this.endpoints.set(nameNormalized, newEndpoint); | |||||
return this; | return this; | ||||
} | } | ||||
} | } | ||||
@@ -71,11 +71,12 @@ export const parseAcceptString = (acceptString?: string) => { | |||||
if (typeof acceptString !== 'string') { | if (typeof acceptString !== 'string') { | ||||
return undefined; | return undefined; | ||||
} | } | ||||
// TODO parse multiple accept types | |||||
const [mediaType, ...acceptParams] = acceptString.split(';'); | const [mediaType, ...acceptParams] = acceptString.split(';'); | ||||
const params = Object.fromEntries( | const params = Object.fromEntries( | ||||
acceptParams.map((s) => { | acceptParams.map((s) => { | ||||
const [key, ...values] = s.split('='); | const [key, ...values] = s.split('='); | ||||
return [key.trim(), ...values.map((v) => v.trim()).join('=')]; | |||||
return [key.trim(), values.map((v) => v.trim()).join('=')]; | |||||
}) | }) | ||||
); | ); | ||||
@@ -14,8 +14,8 @@ export const setupApp = (dataSource: DataSource) => { | |||||
operations, | operations, | ||||
backend: theBackend, | backend: theBackend, | ||||
} = composeRecipes([ | } = composeRecipes([ | ||||
addResourceRecipe({ endpointName: 'users', dataSource, }), | |||||
addResourceRecipe({ endpointName: 'posts', dataSource, }) | |||||
addResourceRecipe({ endpointName: 'users', dataSource, endpointResourceIdAttr: 'id', }), | |||||
addResourceRecipe({ endpointName: 'posts', dataSource, endpointResourceIdAttr: 'id', }) | |||||
])({ | ])({ | ||||
app: app({ | app: app({ | ||||
name: 'default' as const, | name: 'default' as const, | ||||
@@ -16,8 +16,8 @@ import { | |||||
} from '@modal-sh/yasumi/backend'; | } from '@modal-sh/yasumi/backend'; | ||||
import {Client} from '@modal-sh/yasumi/client'; | import {Client} from '@modal-sh/yasumi/client'; | ||||
import {client} from '@modal-sh/yasumi-extender-http/client'; | import {client} from '@modal-sh/yasumi-extender-http/client'; | ||||
import {ResourceItemFetchedResponse} from '@modal-sh/yasumi-recipe-resource'; | |||||
import {createDummyDataSource} from './fixtures/data-source'; | |||||
import {ResourceItemFetchedResponse, ResourceCreatedResponse} from '@modal-sh/yasumi-recipe-resource'; | |||||
import {createDummyDataSource, NEW_ID} from './fixtures/data-source'; | |||||
import {setupApp} from '../src/setup'; | import {setupApp} from '../src/setup'; | ||||
@@ -59,54 +59,82 @@ describe('default', () => { | |||||
await theServer.close(); | await theServer.close(); | ||||
}); | }); | ||||
it('works', async () => { | |||||
const theEndpoint = theClient.app.endpoints.get('users'); | |||||
const theOperation = theClient.app.operations.get('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 responseRaw = await theClient | |||||
.at(theEndpoint) | |||||
.makeRequest( | |||||
theOperation | |||||
.search({ | |||||
foo: 'bar', | |||||
}) | |||||
); | |||||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | |||||
const body = await response['deserialize'](); | |||||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); | |||||
expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); | |||||
expect(body).toEqual([]); | |||||
describe('fetch', () => { | |||||
it('works', async () => { | |||||
const theEndpoint = theClient.app.endpoints.get('users'); | |||||
const theOperation = theClient.app.operations.get('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 responseRaw = await theClient | |||||
.at(theEndpoint) | |||||
.makeRequest( | |||||
theOperation | |||||
.search({ | |||||
foo: 'bar', | |||||
}) | |||||
); | |||||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | |||||
const body = await response['deserialize'](); | |||||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); | |||||
expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); | |||||
expect(body).toEqual([]); | |||||
}); | |||||
it('works for items', async () => { | |||||
const theEndpoint = theClient.app.endpoints.get('users'); | |||||
const theOperation = theClient.app.operations.get('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 responseRaw = await theClient | |||||
.at(theEndpoint, { resourceId: 3 }) | |||||
// 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); | |||||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); | |||||
expect(response).toHaveProperty('statusMessage', 'Resource Fetched'); | |||||
}); | |||||
}); | }); | ||||
it('works for items', async () => { | |||||
const theEndpoint = theClient.app.endpoints.get('users'); | |||||
const theOperation = theClient.app.operations.get('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 responseRaw = await theClient | |||||
.at(theEndpoint, { resourceId: 3 }) | |||||
// 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); | |||||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); | |||||
expect(response).toHaveProperty('statusMessage', 'Resource Item Fetched'); | |||||
describe('create', () => { | |||||
it('works', async () => { | |||||
const theEndpoint = theClient.app.endpoints.get('users'); | |||||
const theOperation = theClient.app.operations.get('create'); | |||||
// 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(theEndpoint) | |||||
.makeRequest( | |||||
theOperation | |||||
.setBody({}) | |||||
); | |||||
const response = ResourceCreatedResponse.fromFetchResponse(responseRaw); | |||||
const body = await response['deserialize'](); | |||||
expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_CREATED); | |||||
expect(response).toHaveProperty('statusMessage', 'Resource Created'); | |||||
expect(body).toEqual({ id: NEW_ID }); | |||||
}); | |||||
}); | }); | ||||
}); | }); |
@@ -1,5 +1,9 @@ | |||||
import {vi} from 'vitest'; | import {vi} from 'vitest'; | ||||
export const NEW_ID = 1; | |||||
export const TOTAL_COUNT = 1; | |||||
export const createDummyDataSource = () => ({ | export const createDummyDataSource = () => ({ | ||||
create: vi.fn(async (data) => data), | create: vi.fn(async (data) => data), | ||||
getById: vi.fn(async () => ({})), | getById: vi.fn(async () => ({})), | ||||
@@ -7,8 +11,8 @@ export const createDummyDataSource = () => ({ | |||||
emplace: vi.fn(async () => [{}, { isCreated: false }]), | emplace: vi.fn(async () => [{}, { isCreated: false }]), | ||||
getMultiple: vi.fn(async () => []), | getMultiple: vi.fn(async () => []), | ||||
getSingle: vi.fn(async () => ({})), | getSingle: vi.fn(async () => ({})), | ||||
getTotalCount: vi.fn(async () => 1), | |||||
newId: vi.fn(async () => 1), | |||||
getTotalCount: vi.fn(async () => TOTAL_COUNT), | |||||
newId: vi.fn(async () => NEW_ID), | |||||
patch: vi.fn(async (id, data) => ({ ...data, id })), | patch: vi.fn(async (id, data) => ({ ...data, id })), | ||||
initialize: vi.fn(async () => {}), | initialize: vi.fn(async () => {}), | ||||
}); | }); |
@@ -75,8 +75,12 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
} | } | ||||
const method = methodRaw.toUpperCase(); | const method = methodRaw.toUpperCase(); | ||||
console.log(language?.name, mediaType?.name, charset?.name); | |||||
console.log(method, url); | console.log(method, url); | ||||
console.log('Accept:', requestHeaders['accept']); | |||||
console.log('Accept-Charset:', requestHeaders['accept-charset']); | |||||
console.log('Accept-Language:', requestHeaders['accept-language']); | |||||
console.log('Content-Language:', language?.name); | |||||
console.log('Content-Type:', `${mediaType?.name};charset=${charset?.name}`); | |||||
const endpoints = parseToEndpointQueue(url, this.backend.app.endpoints); | const endpoints = parseToEndpointQueue(url, this.backend.app.endpoints); | ||||
const [endpoint, endpointParams] = endpoints.at(-1) ?? []; | const [endpoint, endpointParams] = endpoints.at(-1) ?? []; | ||||
@@ -150,7 +154,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
mediaType: requestBodyMediaTypeString, | mediaType: requestBodyMediaTypeString, | ||||
params: requestBodyParams, | params: requestBodyParams, | ||||
} = requestBodySpecs; | } = requestBodySpecs; | ||||
const requestBodyCharsetString = requestBodyParams?.charset; | |||||
const requestBodyCharsetString = requestBodyParams?.charset?.toLowerCase(); | |||||
const requestBodyMediaType = this.backend.app.mediaTypes.get(requestBodyMediaTypeString); | const requestBodyMediaType = this.backend.app.mediaTypes.get(requestBodyMediaTypeString); | ||||
if (typeof requestBodyMediaType === 'undefined') { | if (typeof requestBodyMediaType === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, errorHeaders); | res.writeHead(statusCodes.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, errorHeaders); | ||||
@@ -166,13 +170,20 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
} | } | ||||
const bodyBuffer = await getBody(req); | const bodyBuffer = await getBody(req); | ||||
// TODO set params to decoder/deserializer | |||||
const bodyDecoded = decoder(bodyBuffer, requestBodyParams); | const bodyDecoded = decoder(bodyBuffer, requestBodyParams); | ||||
console.log(); | |||||
console.log(bodyDecoded); | |||||
body = requestBodyMediaType.deserialize(bodyDecoded, requestBodyParams); | body = requestBodyMediaType.deserialize(bodyDecoded, requestBodyParams); | ||||
} | } | ||||
const responseHeaders = {} as Record<string, string>; | |||||
const isCqrs = false; | const isCqrs = false; | ||||
let hasSentResponse = false; | let hasSentResponse = false; | ||||
console.log(); | |||||
const responseSpec = await implementation({ | const responseSpec = await implementation({ | ||||
endpoint, | endpoint, | ||||
body, | body, | ||||
@@ -206,7 +217,6 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
return; | return; | ||||
} | } | ||||
const responseHeaders = {} as Record<string, string>; | |||||
// TODO serialize using content-negotiation params | // TODO serialize using content-negotiation params | ||||
const bodyToSerialize = responseSpec.body; | const bodyToSerialize = responseSpec.body; | ||||
if (bodyToSerialize instanceof Buffer) { | if (bodyToSerialize instanceof Buffer) { | ||||
@@ -228,7 +238,16 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
res.end(); | res.end(); | ||||
return; | return; | ||||
} | } | ||||
serialized = mediaType.serialize(bodyToSerialize) as string; | |||||
serialized = mediaType.serialize(bodyToSerialize); | |||||
console.log(responseSpec.statusCode, responseSpec.statusMessage); | |||||
Object.entries(responseHeaders).forEach(([key, value]) => { | |||||
console.log(`${key}:`, value); | |||||
}); | |||||
console.log(); | |||||
console.log(serialized); | |||||
responseHeaders['Content-Type'] = mediaType.name; | responseHeaders['Content-Type'] = mediaType.name; | ||||
if (typeof charset !== 'undefined' && typeof responseHeaders['Content-Type'] === 'string') { | if (typeof charset !== 'undefined' && typeof responseHeaders['Content-Type'] === 'string') { | ||||
encoded = charset.encode(serialized); | encoded = charset.encode(serialized); | ||||
@@ -5,6 +5,12 @@ import { | |||||
GetEndpointParams, | GetEndpointParams, | ||||
Operation, serializeEndpointQueue, | Operation, serializeEndpointQueue, | ||||
ServiceParams, | ServiceParams, | ||||
FALLBACK_LANGUAGE, | |||||
FALLBACK_MEDIA_TYPE, | |||||
FALLBACK_CHARSET, | |||||
Language, | |||||
MediaType, | |||||
Charset, | |||||
} from '@modal-sh/yasumi'; | } from '@modal-sh/yasumi'; | ||||
import {Client, ClientParams, ClientConnection} from '@modal-sh/yasumi/client'; | import {Client, ClientParams, ClientConnection} from '@modal-sh/yasumi/client'; | ||||
@@ -29,10 +35,23 @@ class ClientInstance<App extends BaseApp> implements Client<App> { | |||||
private readonly fetchFn: typeof fetch; | private readonly fetchFn: typeof fetch; | ||||
private connection?: ServiceParams; | private connection?: ServiceParams; | ||||
private endpointQueue = [] as EndpointQueue; | private endpointQueue = [] as EndpointQueue; | ||||
private requestLanguage?: Language; | |||||
private requestMediaType?: MediaType; | |||||
private requestCharset?: Charset; | |||||
private responseLanguage?: Language; | |||||
private responseMediaType?: MediaType; | |||||
private responseCharset?: Charset; | |||||
constructor(params: ClientParams<App>) { | constructor(params: ClientParams<App>) { | ||||
this.app = params.app; | this.app = params.app; | ||||
this.fetchFn = params.fetch ?? fetch; | this.fetchFn = params.fetch ?? fetch; | ||||
this.requestLanguage = params.app.languages.get(FALLBACK_LANGUAGE.name.toLowerCase()); | |||||
this.requestMediaType = params.app.mediaTypes.get(FALLBACK_MEDIA_TYPE.name.toLowerCase()); | |||||
this.requestCharset = params.app.charsets.get(FALLBACK_CHARSET.name.toLowerCase()); | |||||
this.responseLanguage = params.app.languages.get(FALLBACK_LANGUAGE.name.toLowerCase()); | |||||
this.responseMediaType = params.app.mediaTypes.get(FALLBACK_MEDIA_TYPE.name.toLowerCase()); | |||||
this.responseCharset = params.app.charsets.get(FALLBACK_CHARSET.name.toLowerCase()); | |||||
} | } | ||||
async connect(params: ServiceParams): Promise<ClientConnection> { | async connect(params: ServiceParams): Promise<ClientConnection> { | ||||
@@ -88,13 +107,32 @@ class ClientInstance<App extends BaseApp> implements Client<App> { | |||||
? EXTENSION_METHOD_EFFECTIVE_METHOD | ? EXTENSION_METHOD_EFFECTIVE_METHOD | ||||
: rawEffectiveMethod; | : rawEffectiveMethod; | ||||
const effectiveResponseMediaType = this.responseMediaType ?? FALLBACK_MEDIA_TYPE; | |||||
const effectiveResponseCharset = this.responseCharset ?? FALLBACK_CHARSET; | |||||
const effectiveResponseLanguage = this.responseLanguage ?? FALLBACK_LANGUAGE; | |||||
if (typeof operation.body !== 'undefined') { | if (typeof operation.body !== 'undefined') { | ||||
const effectiveRequestMediaType = this.requestMediaType ?? FALLBACK_MEDIA_TYPE; | |||||
const bodySerialized = effectiveRequestMediaType.serialize(operation.body); | |||||
const effectiveRequestCharset = this.requestCharset ?? FALLBACK_CHARSET; | |||||
const bodyEncoded = effectiveRequestCharset.encode(bodySerialized); | |||||
const effectiveRequestLanguage = this.requestLanguage ?? FALLBACK_LANGUAGE; | |||||
return this.fetchFn( | return this.fetchFn( | ||||
url, | url, | ||||
{ | { | ||||
method: finalEffectiveMethod, | method: finalEffectiveMethod, | ||||
body: operation.body as string, | |||||
// TODO inject headers | |||||
body: bodyEncoded, | |||||
headers: { | |||||
// TODO add wildcard for accept headers to accept anything from the server | |||||
'Accept': effectiveResponseMediaType.name, | |||||
'Accept-Charset': effectiveResponseCharset.name, | |||||
'Accept-Language': effectiveResponseLanguage.name, | |||||
'Content-Language': effectiveRequestLanguage.name, | |||||
'Content-Type': `${effectiveRequestMediaType.name};charset=${effectiveRequestCharset.name}`, | |||||
}, | |||||
}, | }, | ||||
); | ); | ||||
} | } | ||||
@@ -103,7 +141,11 @@ class ClientInstance<App extends BaseApp> implements Client<App> { | |||||
url, | url, | ||||
{ | { | ||||
method: finalEffectiveMethod, | method: finalEffectiveMethod, | ||||
// TODO inject headers | |||||
headers: { | |||||
'Accept': effectiveResponseMediaType.name, | |||||
'Accept-Charset': effectiveResponseCharset.name, | |||||
'Accept-Language': effectiveResponseLanguage.name, | |||||
}, | |||||
}, | }, | ||||
); | ); | ||||
} | } | ||||
@@ -11,6 +11,7 @@ import * as deleteOperation from './implementation/delete'; | |||||
interface AddResourceRecipeParams { | interface AddResourceRecipeParams { | ||||
endpointName: string; | endpointName: string; | ||||
dataSource?: DataSource; | dataSource?: DataSource; | ||||
endpointResourceIdAttr: string; | |||||
// TODO specify the available operations to implement | // TODO specify the available operations to implement | ||||
} | } | ||||
@@ -32,6 +33,7 @@ export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a | |||||
}), | }), | ||||
}) | }) | ||||
.param('resourceId') | .param('resourceId') | ||||
.id(params.endpointResourceIdAttr) | |||||
.can(fetchOperation.name) | .can(fetchOperation.name) | ||||
.can(createOperation.name) | .can(createOperation.name) | ||||
.can(emplaceOperation.name) | .can(emplaceOperation.name) | ||||