diff --git a/packages/core/src/common/app.ts b/packages/core/src/common/app.ts index 2028e22..08eeed5 100644 --- a/packages/core/src/common/app.ts +++ b/packages/core/src/common/app.ts @@ -72,42 +72,43 @@ class AppInstance implemen s.method === newOperation.method )); this.languages = new Map([ - [FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], + [FALLBACK_LANGUAGE.name.toLowerCase(), FALLBACK_LANGUAGE], ]); this.mediaTypes = new Map([ - [FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], + [FALLBACK_MEDIA_TYPE.name.toLowerCase(), FALLBACK_MEDIA_TYPE], ]); this.charsets = new Map([ - [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: NewOperation) { - this.operations.set(newOperation.name, newOperation); + this.operations.set(newOperation.name.toLowerCase(), newOperation); return this; } 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; } } diff --git a/packages/core/src/common/media-type.ts b/packages/core/src/common/media-type.ts index ac4e21f..6946786 100644 --- a/packages/core/src/common/media-type.ts +++ b/packages/core/src/common/media-type.ts @@ -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('=')]; }) ); diff --git a/packages/examples/http-resource-server/src/setup.ts b/packages/examples/http-resource-server/src/setup.ts index 29d91a4..9bb7c56 100644 --- a/packages/examples/http-resource-server/src/setup.ts +++ b/packages/examples/http-resource-server/src/setup.ts @@ -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, diff --git a/packages/examples/http-resource-server/test/default.test.ts b/packages/examples/http-resource-server/test/default.test.ts index ed4cae5..ecbf953 100644 --- a/packages/examples/http-resource-server/test/default.test.ts +++ b/packages/examples/http-resource-server/test/default.test.ts @@ -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 }); + }); }); }); diff --git a/packages/examples/http-resource-server/test/fixtures/data-source.ts b/packages/examples/http-resource-server/test/fixtures/data-source.ts index d5dfde8..e05440c 100644 --- a/packages/examples/http-resource-server/test/fixtures/data-source.ts +++ b/packages/examples/http-resource-server/test/fixtures/data-source.ts @@ -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 () => {}), }); diff --git a/packages/extenders/http/src/backend/core.ts b/packages/extenders/http/src/backend/core.ts index 30d9708..ab21a92 100644 --- a/packages/extenders/http/src/backend/core.ts +++ b/packages/extenders/http/src/backend/core.ts @@ -75,8 +75,12 @@ class ServerInstance implements Server { } 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 implements Server { 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 implements Server { } 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; const isCqrs = false; let hasSentResponse = false; + + console.log(); + const responseSpec = await implementation({ endpoint, body, @@ -206,7 +217,6 @@ class ServerInstance implements Server { return; } - const responseHeaders = {} as Record; // TODO serialize using content-negotiation params const bodyToSerialize = responseSpec.body; if (bodyToSerialize instanceof Buffer) { @@ -228,7 +238,16 @@ class ServerInstance implements Server { 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); diff --git a/packages/extenders/http/src/client/core.ts b/packages/extenders/http/src/client/core.ts index 341e071..55b1437 100644 --- a/packages/extenders/http/src/client/core.ts +++ b/packages/extenders/http/src/client/core.ts @@ -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 implements Client { 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) { 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 { @@ -88,13 +107,32 @@ class ClientInstance implements Client { ? 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 implements Client { url, { method: finalEffectiveMethod, - // TODO inject headers + headers: { + 'Accept': effectiveResponseMediaType.name, + 'Accept-Charset': effectiveResponseCharset.name, + 'Accept-Language': effectiveResponseLanguage.name, + }, }, ); } diff --git a/packages/recipes/resource/src/core.ts b/packages/recipes/resource/src/core.ts index c983dd1..f5f2c9b 100644 --- a/packages/recipes/resource/src/core.ts +++ b/packages/recipes/resource/src/core.ts @@ -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)