Browse Source

Update tests

Ensure new endpoints are following correct request/response content negotiation.
refactor/new-arch
TheoryOfNekomata 6 months ago
parent
commit
517571a6d8
8 changed files with 169 additions and 72 deletions
  1. +11
    -10
      packages/core/src/common/app.ts
  2. +2
    -1
      packages/core/src/common/media-type.ts
  3. +2
    -2
      packages/examples/http-resource-server/src/setup.ts
  4. +77
    -49
      packages/examples/http-resource-server/test/default.test.ts
  5. +6
    -2
      packages/examples/http-resource-server/test/fixtures/data-source.ts
  6. +24
    -5
      packages/extenders/http/src/backend/core.ts
  7. +45
    -3
      packages/extenders/http/src/client/core.ts
  8. +2
    -0
      packages/recipes/resource/src/core.ts

+ 11
- 10
packages/core/src/common/app.ts View File

@@ -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;
}
}


+ 2
- 1
packages/core/src/common/media-type.ts View File

@@ -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('=')];
})
);



+ 2
- 2
packages/examples/http-resource-server/src/setup.ts View File

@@ -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,


+ 77
- 49
packages/examples/http-resource-server/test/default.test.ts View File

@@ -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 });
});
});
});

+ 6
- 2
packages/examples/http-resource-server/test/fixtures/data-source.ts View File

@@ -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 () => {}),
});

+ 24
- 5
packages/extenders/http/src/backend/core.ts View File

@@ -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);


+ 45
- 3
packages/extenders/http/src/client/core.ts View File

@@ -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,
},
},
);
}


+ 2
- 0
packages/recipes/resource/src/core.ts View File

@@ -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)


Loading…
Cancel
Save