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 | |||
)); | |||
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>([ | |||
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], | |||
[FALLBACK_MEDIA_TYPE.name.toLowerCase(), FALLBACK_MEDIA_TYPE], | |||
]); | |||
this.charsets = new Map<Charset['name'], Charset>([ | |||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | |||
[FALLBACK_CHARSET.name.toLowerCase(), FALLBACK_CHARSET], | |||
]); | |||
} | |||
language(language: Language): this { | |||
this.languages.set(language.name, language); | |||
this.languages.set(language.name.toLowerCase(), language); | |||
return this; | |||
} | |||
mediaType(mediaType: MediaType): this { | |||
this.mediaTypes.set(mediaType.name, mediaType); | |||
this.mediaTypes.set(mediaType.name.toLowerCase(), mediaType); | |||
return this; | |||
} | |||
charset(charset: Charset): this { | |||
this.charsets.set(charset.name, charset); | |||
this.charsets.set(charset.name.toLowerCase(), charset); | |||
return this; | |||
} | |||
operation<NewOperation extends Operation>(newOperation: NewOperation) { | |||
this.operations.set(newOperation.name, newOperation); | |||
this.operations.set(newOperation.name.toLowerCase(), newOperation); | |||
return this; | |||
} | |||
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; | |||
} | |||
} | |||
@@ -71,11 +71,12 @@ export const parseAcceptString = (acceptString?: string) => { | |||
if (typeof acceptString !== 'string') { | |||
return undefined; | |||
} | |||
// TODO parse multiple accept types | |||
const [mediaType, ...acceptParams] = acceptString.split(';'); | |||
const params = Object.fromEntries( | |||
acceptParams.map((s) => { | |||
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, | |||
backend: theBackend, | |||
} = composeRecipes([ | |||
addResourceRecipe({ endpointName: 'users', dataSource, }), | |||
addResourceRecipe({ endpointName: 'posts', dataSource, }) | |||
addResourceRecipe({ endpointName: 'users', dataSource, endpointResourceIdAttr: 'id', }), | |||
addResourceRecipe({ endpointName: 'posts', dataSource, endpointResourceIdAttr: 'id', }) | |||
])({ | |||
app: app({ | |||
name: 'default' as const, | |||
@@ -16,8 +16,8 @@ import { | |||
} from '@modal-sh/yasumi/backend'; | |||
import {Client} from '@modal-sh/yasumi/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'; | |||
@@ -59,54 +59,82 @@ describe('default', () => { | |||
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'; | |||
export const NEW_ID = 1; | |||
export const TOTAL_COUNT = 1; | |||
export const createDummyDataSource = () => ({ | |||
create: vi.fn(async (data) => data), | |||
getById: vi.fn(async () => ({})), | |||
@@ -7,8 +11,8 @@ export const createDummyDataSource = () => ({ | |||
emplace: vi.fn(async () => [{}, { isCreated: false }]), | |||
getMultiple: 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 })), | |||
initialize: vi.fn(async () => {}), | |||
}); |
@@ -75,8 +75,12 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
} | |||
const method = methodRaw.toUpperCase(); | |||
console.log(language?.name, mediaType?.name, charset?.name); | |||
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 [endpoint, endpointParams] = endpoints.at(-1) ?? []; | |||
@@ -150,7 +154,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
mediaType: requestBodyMediaTypeString, | |||
params: requestBodyParams, | |||
} = requestBodySpecs; | |||
const requestBodyCharsetString = requestBodyParams?.charset; | |||
const requestBodyCharsetString = requestBodyParams?.charset?.toLowerCase(); | |||
const requestBodyMediaType = this.backend.app.mediaTypes.get(requestBodyMediaTypeString); | |||
if (typeof requestBodyMediaType === 'undefined') { | |||
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); | |||
// TODO set params to decoder/deserializer | |||
const bodyDecoded = decoder(bodyBuffer, requestBodyParams); | |||
console.log(); | |||
console.log(bodyDecoded); | |||
body = requestBodyMediaType.deserialize(bodyDecoded, requestBodyParams); | |||
} | |||
const responseHeaders = {} as Record<string, string>; | |||
const isCqrs = false; | |||
let hasSentResponse = false; | |||
console.log(); | |||
const responseSpec = await implementation({ | |||
endpoint, | |||
body, | |||
@@ -206,7 +217,6 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
return; | |||
} | |||
const responseHeaders = {} as Record<string, string>; | |||
// TODO serialize using content-negotiation params | |||
const bodyToSerialize = responseSpec.body; | |||
if (bodyToSerialize instanceof Buffer) { | |||
@@ -228,7 +238,16 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||
res.end(); | |||
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; | |||
if (typeof charset !== 'undefined' && typeof responseHeaders['Content-Type'] === 'string') { | |||
encoded = charset.encode(serialized); | |||
@@ -5,6 +5,12 @@ import { | |||
GetEndpointParams, | |||
Operation, serializeEndpointQueue, | |||
ServiceParams, | |||
FALLBACK_LANGUAGE, | |||
FALLBACK_MEDIA_TYPE, | |||
FALLBACK_CHARSET, | |||
Language, | |||
MediaType, | |||
Charset, | |||
} from '@modal-sh/yasumi'; | |||
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 connection?: ServiceParams; | |||
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>) { | |||
this.app = params.app; | |||
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> { | |||
@@ -88,13 +107,32 @@ class ClientInstance<App extends BaseApp> implements Client<App> { | |||
? EXTENSION_METHOD_EFFECTIVE_METHOD | |||
: 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') { | |||
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( | |||
url, | |||
{ | |||
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, | |||
{ | |||
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 { | |||
endpointName: string; | |||
dataSource?: DataSource; | |||
endpointResourceIdAttr: string; | |||
// TODO specify the available operations to implement | |||
} | |||
@@ -32,6 +33,7 @@ export const addResourceRecipe = (params: AddResourceRecipeParams): Recipe => (a | |||
}), | |||
}) | |||
.param('resourceId') | |||
.id(params.endpointResourceIdAttr) | |||
.can(fetchOperation.name) | |||
.can(createOperation.name) | |||
.can(emplaceOperation.name) | |||