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 6 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> {
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;
}



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

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

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

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('statusMessage', 'Resource Collection Fetched');
console.log(responseRaw.headers);
});

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 {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 {
Backend as BaseBackend,
Server,
@@ -14,6 +25,85 @@ declare module '@modal-sh/yasumi/backend' {
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> {
readonly backend: Backend;
private serverInternal?: http.Server;
@@ -23,26 +113,55 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
}

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.end();
return;
}
if (typeof req.url === 'undefined') {
if (typeof url === 'undefined') {
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {});
res.end();
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) ?? [];

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

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') {
res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, {
@@ -52,7 +171,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> {
return;
}

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

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

// TODO serialize using content-negotiation params
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.writeHead(responseSpec.statusCode, {});
res.end();
res.writeHead(responseSpec.statusCode, headers);
res.end(encoded);
} catch (errorResponseSpecRaw) {
const responseSpec = errorResponseSpecRaw as ErrorResponse;
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 contentType = 'application/json-patch+json' as const;

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

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 contentType = 'application/merge-patch+json' as const;

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

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


Loading…
Cancel
Save