Browse Source

Update content negotiation processing

Use content negotiation for HTTP extender. TODO: need to genericise the content negotiation logic to apply possibly to other extenders.
refactor/new-arch
TheoryOfNekomata 5 months ago
parent
commit
5db84c2c81
6 changed files with 173 additions and 19 deletions
  1. +14
    -1
      packages/core/src/client/index.ts
  2. +4
    -0
      packages/core/src/common/operation.ts
  3. +1
    -0
      packages/examples/http-resource-server/test/default.test.ts
  4. +144
    -18
      packages/extenders/http/src/backend/core.ts
  5. +5
    -0
      packages/recipes/resource/src/implementation/patch-delta.ts
  6. +5
    -0
      packages/recipes/resource/src/implementation/patch-merge.ts

+ 14
- 1
packages/core/src/client/index.ts View File

@@ -1,7 +1,20 @@
import {ServiceParams, App as BaseApp, Endpoint, GetEndpointParams, Operation} from '../common';
import {
ServiceParams,
App as BaseApp,
Endpoint,
GetEndpointParams,
Operation,
Language,
MediaType,
Charset,
} from '../common';


export interface ClientParams<App extends BaseApp> { export interface ClientParams<App extends BaseApp> {
app: App; app: App;
// todo only select from app's registered languages/media types/charsets
language?: Language['name'];
mediaType?: MediaType['name'];
charset?: Charset['name'];
fetch?: typeof fetch; fetch?: typeof fetch;
} }




+ 4
- 0
packages/core/src/common/operation.ts View File

@@ -22,11 +22,13 @@ export interface BaseOperationParams<
> { > {
name: Name; name: Name;
method?: Method; method?: Method;
headers?: ConstructorParameters<typeof Headers>[0];
} }


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'];
headers?: Headers;
searchParams?: URLSearchParams; searchParams?: URLSearchParams;
search: (...args: ConstructorParameters<typeof URLSearchParams>) => Operation<Params>; search: (...args: ConstructorParameters<typeof URLSearchParams>) => Operation<Params>;
setBody: (b: unknown) => Operation<Params>; setBody: (b: unknown) => Operation<Params>;
@@ -36,6 +38,7 @@ export interface Operation<Params extends BaseOperationParams = BaseOperationPar
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'];
readonly headers?: Headers;
theSearchParams?: URLSearchParams; theSearchParams?: URLSearchParams;
// todo add type safety, depend on method when allowing to have body // todo add type safety, depend on method when allowing to have body
theBody?: unknown; theBody?: unknown;
@@ -43,6 +46,7 @@ class OperationInstance<Params extends BaseOperationParams = BaseOperationParams
constructor(params: Params) { constructor(params: Params) {
this.name = params.name; this.name = params.name;
this.method = params.method ?? 'GET'; this.method = params.method ?? 'GET';
this.headers = typeof params.headers !== 'undefined' ? new Headers(params.headers) : undefined;
} }


search(...args: ConstructorParameters<typeof URLSearchParams>): Operation<Params> { search(...args: ConstructorParameters<typeof URLSearchParams>): Operation<Params> {


+ 1
- 0
packages/examples/http-resource-server/test/default.test.ts View File

@@ -81,6 +81,7 @@ describe('default', () => {


expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK); expect(response).toHaveProperty('statusCode', statusCodes.HTTP_STATUS_OK);
expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched'); expect(response).toHaveProperty('statusMessage', 'Resource Collection Fetched');
console.log(responseRaw.headers);
}); });


it('works for items', async () => { it('works for items', async () => {


+ 144
- 18
packages/extenders/http/src/backend/core.ts View File

@@ -1,5 +1,16 @@
import http from 'http'; import http from 'http';
import {ErrorResponse, parseToEndpointQueue, ServiceParams, statusCodes} from '@modal-sh/yasumi';
import {
ErrorResponse,
parseToEndpointQueue,
ServiceParams,
statusCodes,
FALLBACK_LANGUAGE,
Language,
FALLBACK_MEDIA_TYPE,
MediaType,
FALLBACK_CHARSET,
Charset,
} from '@modal-sh/yasumi';
import { import {
Backend as BaseBackend, Backend as BaseBackend,
Server, Server,
@@ -14,6 +25,85 @@ declare module '@modal-sh/yasumi/backend' {
interface ServerResponseContext extends http.ServerResponse {} interface ServerResponseContext extends http.ServerResponse {}
} }


const isTextMediaType = (mediaType: string) => {
const { type, subtype } = parseMediaType(mediaType);
if (type === 'text') {
return true;
}

if (type === 'application' && subtype.length > 0) {
const lastSubtype = subtype.at(-1) as string;
return [
'json',
'xml'
].includes(lastSubtype);
}

return false;
};

const getLanguage = (languageString?: string): Language => {
if (typeof languageString === 'undefined') {
return FALLBACK_LANGUAGE;
}
// TODO negotiator
return FALLBACK_LANGUAGE;
};

const getMediaType = (mediaTypeString?: string): MediaType => {
if (typeof mediaTypeString === 'undefined') {
return FALLBACK_MEDIA_TYPE;
}
return FALLBACK_MEDIA_TYPE;
};

const getCharset = (mediaTypeString: string, charset?: string): Charset | undefined => {
if (typeof charset === 'undefined') {
return isTextMediaType(mediaTypeString) ? FALLBACK_CHARSET : undefined;
}
return isTextMediaType(mediaTypeString) ? FALLBACK_CHARSET : undefined;
};

const parseMediaType = (mediaType: string) => {
const [type, subtypeEntirety] = mediaType.split('/').map((s) => s.trim());
return {
type,
// application/foo+json
subtype: subtypeEntirety.split('+'),
};
};

const FALLBACK_BINARY_CHARSET = 'application/octet-stream';

const parseAcceptString = (acceptString?: string) => {
if (typeof acceptString !== 'string') {
return undefined;
}
const [mediaType = FALLBACK_BINARY_CHARSET, ...acceptParams] = acceptString.split(';');
const acceptCharset = acceptParams
.map((s) => s.replace(/\s+/g, ''))
.find((p) => p.startsWith('charset='));
if (typeof acceptCharset !== 'undefined') {
const [, acceptCharsetValue] = acceptCharset.split('=');
return {
mediaType,
charset: acceptCharsetValue,
};
}

if (isTextMediaType(mediaType)) {
return {
// TODO validate if valid mediaType
mediaType,
charset: FALLBACK_CHARSET.name,
};
}

return {
mediaType,
};
};

class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
readonly backend: Backend; readonly backend: Backend;
private serverInternal?: http.Server; private serverInternal?: http.Server;
@@ -23,26 +113,55 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
} }


private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => { private readonly requestListener = async (req: ServerRequestContext, res: ServerResponseContext) => {
// const endpoints = this.backend.app.endpoints;
if (typeof req.method === 'undefined') {
const { method: methodRaw, url, headers } = req;
const language = getLanguage(headers['accept-language']);
const parsedAccept = parseAcceptString(headers['accept']);
const mediaType = typeof parsedAccept !== 'undefined' ? getMediaType(parsedAccept.mediaType) : undefined;
const charset = typeof parsedAccept !== 'undefined' && typeof mediaType !== 'undefined' ? getCharset(mediaType.name, charsetString) : undefined;

if (typeof methodRaw === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {});
res.end(); res.end();
return; return;
} }
if (typeof req.url === 'undefined') {
if (typeof url === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {});
res.end(); res.end();
return; return;
} }


console.log(req.method, req.url);
const method = methodRaw.toUpperCase();
console.log(method, url);


const endpoints = parseToEndpointQueue(req.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) ?? [];


if (typeof endpoint === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_NOT_FOUND);
res.end();
return;
}

const appOperations = Array.from(this.backend.app.operations.values()) const appOperations = Array.from(this.backend.app.operations.values())
const foundAppOperation = appOperations
.find((op) => op.method === req.method?.toUpperCase());
const foundAppOperation = appOperations.find((op) => {
const doesMethodMatch = op.method === method;
if (typeof op.headers !== 'undefined') {
const doesHeadersMatch = Array.from(op.headers.entries()).reduce(
(currentHeadersMatch, [headerKey, opHeaderValue]) => (
// TODO honor content-type matching
currentHeadersMatch && headers[headerKey.toLowerCase()] === opHeaderValue
),
true,
);

return (
doesMethodMatch
&& doesHeadersMatch
);
}

return doesMethodMatch;
});


if (typeof foundAppOperation === 'undefined') { if (typeof foundAppOperation === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, {
@@ -52,7 +171,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
return; return;
} }


if (!endpoint?.operations?.has(foundAppOperation.name)) {
if (!endpoint.operations.has(foundAppOperation.name)) {
const endpointOperations = Array.from(endpoint?.operations ?? []); 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
@@ -70,13 +189,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
return; return;
} }


if (typeof endpoint === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED);
res.end();
return;
}

const [, search] = req.url.split('?');
const [, search] = url.split('?');


// TODO get content negotiation params // TODO get content negotiation params


@@ -98,10 +211,23 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {


// TODO serialize using content-negotiation params // TODO serialize using content-negotiation params
const bodyToSerialize = responseSpec.body; const bodyToSerialize = responseSpec.body;
let encoded = Buffer.from('');
const headers = {} as Record<string, string>;
if (typeof bodyToSerialize === 'object' && bodyToSerialize !== null) {
let serialized = '';
if (typeof mediaType !== 'undefined') {
serialized = mediaType.serialize(bodyToSerialize) as string;
headers['Content-Type'] = mediaType.name;
}
if (typeof charset !== 'undefined' && typeof headers['Content-Type'] === 'string') {
encoded = charset.encode(serialized);
headers['Content-Type'] += `;charset=${charset.name}`;
}
}


res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code
res.writeHead(responseSpec.statusCode, {});
res.end();
res.writeHead(responseSpec.statusCode, headers);
res.end(encoded);
} catch (errorResponseSpecRaw) { } catch (errorResponseSpecRaw) {
const responseSpec = errorResponseSpecRaw as ErrorResponse; const responseSpec = errorResponseSpecRaw as ErrorResponse;
res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code res.statusMessage = responseSpec.statusMessage; // TODO add default status message per status code


+ 5
- 0
packages/recipes/resource/src/implementation/patch-delta.ts View File

@@ -5,9 +5,14 @@ export const name = 'patchDelta' as const;


export const method = 'PATCH' as const; export const method = 'PATCH' as const;


export const contentType = 'application/json-patch+json' as const;

export const operation = defineOperation({ export const operation = defineOperation({
name, name,
method, method,
headers: {
'Content-Type': contentType,
},
}); });


export const implementation: ImplementationFunction = async (ctx) => { export const implementation: ImplementationFunction = async (ctx) => {


+ 5
- 0
packages/recipes/resource/src/implementation/patch-merge.ts View File

@@ -5,9 +5,14 @@ export const name = 'patchMerge' as const;


export const method = 'PATCH' as const; export const method = 'PATCH' as const;


export const contentType = 'application/merge-patch+json' as const;

export const operation = defineOperation({ export const operation = defineOperation({
name, name,
method, method,
headers: {
'Content-Type': contentType,
},
}); });


export const implementation: ImplementationFunction = async (ctx) => { export const implementation: ImplementationFunction = async (ctx) => {


Loading…
Cancel
Save