Ensure both backend and client honors the content negotiation headers.refactor/new-arch
@@ -13,6 +13,7 @@ | |||||
"pridepack" | "pridepack" | ||||
], | ], | ||||
"devDependencies": { | "devDependencies": { | ||||
"@types/negotiator": "^0.6.3", | |||||
"@types/node": "^20.11.0", | "@types/node": "^20.11.0", | ||||
"pridepack": "2.6.0", | "pridepack": "2.6.0", | ||||
"tslib": "^2.6.2", | "tslib": "^2.6.2", | ||||
@@ -44,6 +45,7 @@ | |||||
"access": "public" | "access": "public" | ||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"negotiator": "^0.6.3", | |||||
"valibot": "^0.30.0" | "valibot": "^0.30.0" | ||||
}, | }, | ||||
"types": "./dist/types/common/index.d.ts", | "types": "./dist/types/common/index.d.ts", | ||||
@@ -8,6 +8,7 @@ interface BackendParams<App extends BaseApp> { | |||||
export interface ImplementationContext { | export interface ImplementationContext { | ||||
endpoint: Endpoint; | endpoint: Endpoint; | ||||
body?: unknown; | |||||
params: Record<string, unknown>; | params: Record<string, unknown>; | ||||
query?: URLSearchParams; | query?: URLSearchParams; | ||||
dataSource?: DataSource; | dataSource?: DataSource; | ||||
@@ -1,6 +1,9 @@ | |||||
import {Endpoint, EndpointOperations} from './endpoint'; | import {Endpoint, EndpointOperations} from './endpoint'; | ||||
import {Operation} from './operation'; | import {Operation} from './operation'; | ||||
import {NamedSet, PredicateMap} from './common'; | import {NamedSet, PredicateMap} from './common'; | ||||
import {FALLBACK_LANGUAGE, Language} from './language'; | |||||
import {FALLBACK_MEDIA_TYPE, MediaType} from './media-type'; | |||||
import {Charset, FALLBACK_CHARSET} from './charset'; | |||||
export interface BaseAppState { | export interface BaseAppState { | ||||
endpoints: unknown; | endpoints: unknown; | ||||
@@ -23,6 +26,13 @@ export interface App<AppName extends string = string, AppState extends BaseAppSt | |||||
name: AppName; | name: AppName; | ||||
endpoints: NamedSet<Endpoint>; | endpoints: NamedSet<Endpoint>; | ||||
operations: NamedSet<Operation>; | operations: NamedSet<Operation>; | ||||
languages: NamedSet<Language>; | |||||
mediaTypes: NamedSet<MediaType>; | |||||
charsets: NamedSet<Charset>; | |||||
// todo add stateful types with these methods | |||||
language(language: Language): this; | |||||
mediaType(mediaType: MediaType): this; | |||||
charset(charset: Charset): this; | |||||
operation<NewOperation extends Operation>(newOperation: NewOperation): App< | operation<NewOperation extends Operation>(newOperation: NewOperation): App< | ||||
AppName, | AppName, | ||||
{ | { | ||||
@@ -51,6 +61,9 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen | |||||
readonly name: Params['name']; | readonly name: Params['name']; | ||||
readonly endpoints: NamedSet<Endpoint>; | readonly endpoints: NamedSet<Endpoint>; | ||||
readonly operations: NamedSet<Operation>; | readonly operations: NamedSet<Operation>; | ||||
readonly languages: NamedSet<Language>; | |||||
readonly mediaTypes: NamedSet<MediaType>; | |||||
readonly charsets: NamedSet<Charset>; | |||||
constructor(params: Params) { | constructor(params: Params) { | ||||
this.name = params.name; | this.name = params.name; | ||||
@@ -58,6 +71,30 @@ class AppInstance<Params extends AppParams, State extends BaseAppState> implemen | |||||
this.operations = new PredicateMap<Operation['name'], Operation>((newOperation, s) => ( | this.operations = new PredicateMap<Operation['name'], Operation>((newOperation, s) => ( | ||||
s.method === newOperation.method | s.method === newOperation.method | ||||
)); | )); | ||||
this.languages = new Map<Language['name'], Language>([ | |||||
[FALLBACK_LANGUAGE.name, FALLBACK_LANGUAGE], | |||||
]); | |||||
this.mediaTypes = new Map<MediaType['name'], MediaType>([ | |||||
[FALLBACK_MEDIA_TYPE.name, FALLBACK_MEDIA_TYPE], | |||||
]); | |||||
this.charsets = new Map<Charset['name'], Charset>([ | |||||
[FALLBACK_CHARSET.name, FALLBACK_CHARSET], | |||||
]); | |||||
} | |||||
language(language: Language): this { | |||||
this.languages.set(language.name, language); | |||||
return this; | |||||
} | |||||
mediaType(mediaType: MediaType): this { | |||||
this.mediaTypes.set(mediaType.name, mediaType); | |||||
return this; | |||||
} | |||||
charset(charset: Charset): this { | |||||
this.charsets.set(charset.name, charset); | |||||
return this; | |||||
} | } | ||||
operation<NewOperation extends Operation>(newOperation: NewOperation) { | operation<NewOperation extends Operation>(newOperation: NewOperation) { | ||||
@@ -1,3 +1,6 @@ | |||||
import {isTextMediaType} from './media-type'; | |||||
import Negotiator from 'negotiator'; | |||||
export interface Charset<Name extends string = string> { | export interface Charset<Name extends string = string> { | ||||
name: Name; | name: Name; | ||||
encode: (str: string) => Buffer; | encode: (str: string) => Buffer; | ||||
@@ -9,3 +12,17 @@ export const FALLBACK_CHARSET = { | |||||
decode: (buf: Buffer) => buf.toString('utf-8'), | decode: (buf: Buffer) => buf.toString('utf-8'), | ||||
name: 'utf-8' as const, | name: 'utf-8' as const, | ||||
} satisfies Charset; | } satisfies Charset; | ||||
export const getCharset = (availableCharsets: string[], mediaTypeString: string, charset?: string): Charset['name'] | undefined => { | |||||
if (typeof charset === 'undefined') { | |||||
return isTextMediaType(mediaTypeString) ? FALLBACK_CHARSET['name'] : undefined; | |||||
} | |||||
const negotiator = new Negotiator({ | |||||
headers: { | |||||
'accept': `${mediaTypeString};charset=${charset}`, | |||||
}, | |||||
}); | |||||
return negotiator.charset(availableCharsets); | |||||
}; |
@@ -0,0 +1,48 @@ | |||||
import {App} from './app'; | |||||
import {getLanguage} from './language'; | |||||
import {getMediaType, parseAcceptString} from './media-type'; | |||||
import {getCharset} from './charset'; | |||||
interface ContentNegotiationHeaders { | |||||
'accept-language'?: string; | |||||
accept?: string; | |||||
} | |||||
export const getContentNegotiationParams = (app: App, headers: ContentNegotiationHeaders) => { | |||||
const languageString = getLanguage(Array.from(app.languages.keys()), headers['accept-language']); | |||||
const language = ( | |||||
typeof languageString !== 'undefined' | |||||
? app.languages.get(languageString) | |||||
: undefined | |||||
); | |||||
const parsedAccept = parseAcceptString(headers['accept']); | |||||
const mediaTypeString = ( | |||||
typeof parsedAccept !== 'undefined' | |||||
? getMediaType(Array.from(app.mediaTypes.keys()), parsedAccept.mediaType) | |||||
: undefined | |||||
); | |||||
const mediaType = ( | |||||
typeof mediaTypeString !== 'undefined' | |||||
? app.mediaTypes.get(mediaTypeString) | |||||
: undefined | |||||
); | |||||
const charsetString = ( | |||||
typeof parsedAccept !== 'undefined' && typeof mediaType !== 'undefined' | |||||
? getCharset(Array.from(app.charsets.keys()), mediaType.name, parsedAccept.params.charset) | |||||
: undefined | |||||
); | |||||
const charset = ( | |||||
typeof charsetString !== 'undefined' | |||||
? app.charsets.get(charsetString) | |||||
: undefined | |||||
); | |||||
return { | |||||
language, | |||||
mediaType, | |||||
charset, | |||||
}; | |||||
}; |
@@ -1,5 +1,7 @@ | |||||
export * from './app'; | export * from './app'; | ||||
export * from './charset'; | export * from './charset'; | ||||
export * from './common'; | |||||
export * from './content-negotiation'; | |||||
export * from './endpoint'; | export * from './endpoint'; | ||||
export * from './language'; | export * from './language'; | ||||
export * from './media-type'; | export * from './media-type'; | ||||
@@ -1,3 +1,5 @@ | |||||
import Negotiator from 'negotiator'; | |||||
export type MessageBody = string | string[] | (string | string[])[]; | export type MessageBody = string | string[] | (string | string[])[]; | ||||
export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [ | export const LANGUAGE_DEFAULT_ERROR_STATUS_MESSAGE_KEYS = [ | ||||
@@ -340,3 +342,17 @@ export const FALLBACK_LANGUAGE = { | |||||
], | ], | ||||
}, | }, | ||||
} satisfies Language; | } satisfies Language; | ||||
export const getLanguage = (availableLanguageNames: string[], languageString?: string): Language['name'] | undefined => { | |||||
if (typeof languageString === 'undefined') { | |||||
return FALLBACK_LANGUAGE['name']; | |||||
} | |||||
const negotiator = new Negotiator({ | |||||
headers: { | |||||
'accept-language': languageString, | |||||
}, | |||||
}); | |||||
return negotiator.language(availableLanguageNames); | |||||
}; |
@@ -1,6 +1,8 @@ | |||||
import Negotiator from 'negotiator'; | |||||
export interface MediaType< | export interface MediaType< | ||||
Name extends string = string, | Name extends string = string, | ||||
T extends object = object, | |||||
T extends object | null = object | null, | |||||
SerializeOpts extends {} = {}, | SerializeOpts extends {} = {}, | ||||
DeserializeOpts extends {} = {} | DeserializeOpts extends {} = {} | ||||
> { | > { | ||||
@@ -26,12 +28,59 @@ export const getAcceptPostString = (mediaTypes: Map<string, MediaType>) => Array | |||||
.filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType)) | .filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType)) | ||||
.join(','); | .join(','); | ||||
export const isTextMediaType = (mediaType: string) => ( | |||||
mediaType.startsWith('text/') | |||||
|| [ | |||||
'application/json', | |||||
'application/xml', | |||||
'application/x-www-form-urlencoded', | |||||
...PATCH_CONTENT_TYPES, | |||||
].includes(mediaType) | |||||
); | |||||
export const parseMediaType = (mediaType: string) => { | |||||
const [type, subtypeEntirety] = mediaType.split('/').map((s) => s.trim()); | |||||
return { | |||||
type, | |||||
// application/foo+json | |||||
subtype: subtypeEntirety.split('+'), | |||||
}; | |||||
}; | |||||
export 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; | |||||
}; | |||||
export const getMediaType = (availableMediaTypes: string[], mediaTypeString?: string): MediaType['name'] | undefined => { | |||||
if (typeof mediaTypeString === 'undefined') { | |||||
return FALLBACK_MEDIA_TYPE['name']; | |||||
} | |||||
const negotiator = new Negotiator({ | |||||
headers: { | |||||
'accept': mediaTypeString, | |||||
}, | |||||
}); | |||||
return negotiator.mediaType(availableMediaTypes); | |||||
}; | |||||
export const parseAcceptString = (acceptString?: string) => { | |||||
if (typeof acceptString !== 'string') { | |||||
return undefined; | |||||
} | |||||
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 { | |||||
mediaType, | |||||
params, | |||||
}; | |||||
}; |
@@ -1,4 +1,5 @@ | |||||
import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; | import {ErrorStatusCode, isErrorStatusCode, StatusCode} from './status-codes'; | ||||
import {parseAcceptString, parseMediaType} from './media-type'; | |||||
type FetchResponse = Awaited<ReturnType<typeof fetch>>; | type FetchResponse = Awaited<ReturnType<typeof fetch>>; | ||||
@@ -68,14 +69,29 @@ export const HttpResponse = < | |||||
} | } | ||||
const contentType = response.headers.get('Content-Type'); | const contentType = response.headers.get('Content-Type'); | ||||
// TODO properly parse media type | |||||
if (contentType === 'application/json') { | |||||
if (typeof contentType !== 'string') { | |||||
return await response.arrayBuffer(); | |||||
} | |||||
const parsedAcceptString = parseAcceptString(contentType); | |||||
if (typeof parsedAcceptString?.mediaType !== 'string') { | |||||
return await response.arrayBuffer(); | |||||
} | |||||
const parsedMediaType = parseMediaType(parsedAcceptString.mediaType); | |||||
const isJson = ( | |||||
['application', 'text'].includes(parsedMediaType.type) | |||||
&& parsedMediaType.subtype.at(-1) === 'json' | |||||
); | |||||
if (isJson) { | |||||
return await response.json(); | return await response.json(); | ||||
} | } | ||||
const buffer = await response.arrayBuffer(); | |||||
return buffer; | |||||
// TODO deserialize buffer | |||||
if (parsedMediaType.type === 'text') { | |||||
return await response.text(); | |||||
} | |||||
return await response.arrayBuffer(); | |||||
}, | }, | ||||
}; | }; | ||||
} | } | ||||
@@ -78,10 +78,11 @@ describe('default', () => { | |||||
); | ); | ||||
const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | const response = ResourceItemFetchedResponse.fromFetchResponse(responseRaw); | ||||
const body = await response['deserialize'](); | |||||
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); | |||||
expect(body).toEqual([]); | |||||
}); | }); | ||||
it('works for items', async () => { | it('works for items', async () => { | ||||
@@ -4,12 +4,11 @@ import { | |||||
parseToEndpointQueue, | parseToEndpointQueue, | ||||
ServiceParams, | ServiceParams, | ||||
statusCodes, | statusCodes, | ||||
getContentNegotiationParams, | |||||
FALLBACK_LANGUAGE, | FALLBACK_LANGUAGE, | ||||
Language, | |||||
FALLBACK_MEDIA_TYPE, | |||||
MediaType, | |||||
FALLBACK_CHARSET, | FALLBACK_CHARSET, | ||||
Charset, | |||||
FALLBACK_MEDIA_TYPE, | |||||
parseAcceptString, | |||||
} from '@modal-sh/yasumi'; | } from '@modal-sh/yasumi'; | ||||
import { | import { | ||||
Backend as BaseBackend, | Backend as BaseBackend, | ||||
@@ -25,84 +24,21 @@ 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, | |||||
}; | |||||
}; | |||||
const getBody = ( | |||||
req: http.IncomingMessage, | |||||
) => new Promise<Buffer>((resolve, reject) => { | |||||
let body = Buffer.from(''); | |||||
req.on('data', (chunk) => { | |||||
body = Buffer.concat([body, chunk]); | |||||
}); | |||||
req.on('end', async () => { | |||||
resolve(body); | |||||
}); | |||||
req.on('error', (err) => { | |||||
reject(err); | |||||
}); | |||||
}); | |||||
class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | ||||
readonly backend: Backend; | readonly backend: Backend; | ||||
@@ -113,31 +49,40 @@ 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 { 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; | |||||
const { method: methodRaw, url, headers: requestHeaders } = req; | |||||
const { | |||||
language, | |||||
mediaType, | |||||
charset, | |||||
} = getContentNegotiationParams(this.backend.app, requestHeaders); | |||||
const fallbackLanguage = language ?? FALLBACK_LANGUAGE; | |||||
// TODO use these for errors | |||||
const fallbackMediaType = mediaType ?? FALLBACK_MEDIA_TYPE; | |||||
const fallbackCharset = charset ?? FALLBACK_CHARSET; | |||||
const errorHeaders = { | |||||
'content-language': fallbackLanguage.name, | |||||
} as Record<string, string>; | |||||
if (typeof methodRaw === 'undefined') { | if (typeof methodRaw === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); | |||||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, errorHeaders); | |||||
res.end(); | res.end(); | ||||
return; | return; | ||||
} | } | ||||
if (typeof url === 'undefined') { | if (typeof url === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, {}); | |||||
res.writeHead(statusCodes.HTTP_STATUS_BAD_REQUEST, errorHeaders); | |||||
res.end(); | res.end(); | ||||
return; | return; | ||||
} | } | ||||
const method = methodRaw.toUpperCase(); | const method = methodRaw.toUpperCase(); | ||||
console.log(language?.name, mediaType?.name, charset?.name); | |||||
console.log(method, url); | console.log(method, url); | ||||
const endpoints = parseToEndpointQueue(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') { | if (typeof endpoint === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_NOT_FOUND); | |||||
res.writeHead(statusCodes.HTTP_STATUS_NOT_FOUND, errorHeaders); | |||||
res.end(); | res.end(); | ||||
return; | return; | ||||
} | } | ||||
@@ -149,7 +94,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
const doesHeadersMatch = Array.from(op.headers.entries()).reduce( | const doesHeadersMatch = Array.from(op.headers.entries()).reduce( | ||||
(currentHeadersMatch, [headerKey, opHeaderValue]) => ( | (currentHeadersMatch, [headerKey, opHeaderValue]) => ( | ||||
// TODO honor content-type matching | // TODO honor content-type matching | ||||
currentHeadersMatch && headers[headerKey.toLowerCase()] === opHeaderValue | |||||
currentHeadersMatch && requestHeaders[headerKey.toLowerCase()] === opHeaderValue | |||||
), | ), | ||||
true, | true, | ||||
); | ); | ||||
@@ -165,6 +110,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
if (typeof foundAppOperation === 'undefined') { | if (typeof foundAppOperation === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { | res.writeHead(statusCodes.HTTP_STATUS_METHOD_NOT_ALLOWED, { | ||||
...errorHeaders, | |||||
'Allow': appOperations.map((op) => op.method).join(',') | 'Allow': appOperations.map((op) => op.method).join(',') | ||||
}); | }); | ||||
res.end(); | res.end(); | ||||
@@ -174,6 +120,7 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
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, { | ||||
...errorHeaders, | |||||
'Allow': endpointOperations | 'Allow': endpointOperations | ||||
.map((a) => appOperations.find((aa) => aa.name === a)?.method) | .map((a) => appOperations.find((aa) => aa.name === a)?.method) | ||||
.join(',') | .join(',') | ||||
@@ -184,54 +131,96 @@ class ServerInstance<Backend extends BaseBackend> implements Server<Backend> { | |||||
const implementation = this.backend.implementations.get(foundAppOperation.name); | const implementation = this.backend.implementations.get(foundAppOperation.name); | ||||
if (typeof implementation === 'undefined') { | if (typeof implementation === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED); | |||||
res.writeHead(statusCodes.HTTP_STATUS_NOT_IMPLEMENTED, errorHeaders); | |||||
res.end(); | res.end(); | ||||
return; | return; | ||||
} | } | ||||
const [, search] = url.split('?'); | const [, search] = url.split('?'); | ||||
// TODO get content negotiation params | |||||
// TODO add flag on implementation context if CQRS should be enabled | // TODO add flag on implementation context if CQRS should be enabled | ||||
try { | try { | ||||
// TODO deserialize body, else throw 415 | |||||
const requestBodySpecs = parseAcceptString(requestHeaders['content-type']); | |||||
let body: unknown = undefined; | |||||
// TODO check body | |||||
if (typeof requestBodySpecs !== 'undefined') { | |||||
const { | |||||
mediaType: requestBodyMediaTypeString, | |||||
params: requestBodyParams, | |||||
} = requestBodySpecs; | |||||
const requestBodyCharsetString = requestBodyParams?.charset; | |||||
const requestBodyMediaType = this.backend.app.mediaTypes.get(requestBodyMediaTypeString); | |||||
if (typeof requestBodyMediaType === 'undefined') { | |||||
res.writeHead(statusCodes.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, errorHeaders); | |||||
res.end(); | |||||
return; | |||||
} | |||||
let decoder = (buf: Buffer, _params?: {}) => buf.toString('binary'); | |||||
if (typeof requestBodyCharsetString !== 'undefined') { | |||||
const requestBodyCharset = this.backend.app.charsets.get(requestBodyCharsetString); | |||||
if (typeof requestBodyCharset !== 'undefined') { | |||||
decoder = requestBodyCharset.decode; | |||||
} | |||||
} | |||||
const bodyBuffer = await getBody(req); | |||||
// TODO set params to decoder/deserializer | |||||
const bodyDecoded = decoder(bodyBuffer, requestBodyParams); | |||||
body = requestBodyMediaType.deserialize(bodyDecoded, requestBodyParams); | |||||
} | |||||
const responseSpec = await implementation({ | const responseSpec = await implementation({ | ||||
endpoint, | endpoint, | ||||
body, | |||||
params: endpointParams ?? {}, | params: endpointParams ?? {}, | ||||
query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | query: typeof search !== 'undefined' ? new URLSearchParams(search) : undefined, | ||||
dataSource: this.backend.dataSource, | dataSource: this.backend.dataSource, | ||||
}); | }); | ||||
if (typeof responseSpec === 'undefined') { | if (typeof responseSpec === 'undefined') { | ||||
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, {}); | |||||
res.writeHead(statusCodes.HTTP_STATUS_UNPROCESSABLE_ENTITY, errorHeaders); | |||||
res.end(); | res.end(); | ||||
return; | return; | ||||
} | } | ||||
const responseHeaders = {} as Record<string, string>; | |||||
// TODO serialize using content-negotiation params | // TODO serialize using content-negotiation params | ||||
const bodyToSerialize = responseSpec.body; | const bodyToSerialize = responseSpec.body; | ||||
if (bodyToSerialize instanceof Buffer) { | |||||
// TODO make easy and consistent way to set headers without having to mind about casing | |||||
responseHeaders['Content-Type'] = responseHeaders['Content-Type'] ?? 'application/octet-stream'; | |||||
res.statusMessage = responseSpec.statusMessage; | |||||
res.writeHead(responseSpec.statusCode, responseHeaders) | |||||
res.end(bodyToSerialize); | |||||
return; | |||||
} | |||||
let encoded = Buffer.from(''); | let encoded = Buffer.from(''); | ||||
const headers = {} as Record<string, string>; | |||||
if (typeof bodyToSerialize === 'object' && bodyToSerialize !== null) { | |||||
// TODO throw not acceptable when cannot serialize | |||||
if (typeof bodyToSerialize === 'object') { | |||||
let serialized = ''; | let serialized = ''; | ||||
if (typeof mediaType !== 'undefined') { | |||||
serialized = mediaType.serialize(bodyToSerialize) as string; | |||||
headers['Content-Type'] = mediaType.name; | |||||
if (typeof mediaType === 'undefined') { | |||||
res.writeHead(statusCodes.HTTP_STATUS_NOT_ACCEPTABLE, errorHeaders); | |||||
res.end(); | |||||
return; | |||||
} | } | ||||
if (typeof charset !== 'undefined' && typeof headers['Content-Type'] === 'string') { | |||||
serialized = mediaType.serialize(bodyToSerialize) as string; | |||||
responseHeaders['Content-Type'] = mediaType.name; | |||||
if (typeof charset !== 'undefined' && typeof responseHeaders['Content-Type'] === 'string') { | |||||
encoded = charset.encode(serialized); | encoded = charset.encode(serialized); | ||||
headers['Content-Type'] += `;charset=${charset.name}`; | |||||
responseHeaders['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, headers); | |||||
res.writeHead(responseSpec.statusCode, responseHeaders); | |||||
res.end(encoded); | 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 | ||||
res.writeHead(responseSpec.statusCode, {}); | |||||
res.writeHead(responseSpec.statusCode, errorHeaders); | |||||
res.end(); | res.end(); | ||||
} | } | ||||
}; | }; | ||||
@@ -8,10 +8,16 @@ importers: | |||||
packages/core: | packages/core: | ||||
dependencies: | dependencies: | ||||
negotiator: | |||||
specifier: ^0.6.3 | |||||
version: 0.6.3 | |||||
valibot: | valibot: | ||||
specifier: ^0.30.0 | specifier: ^0.30.0 | ||||
version: 0.30.0 | version: 0.30.0 | ||||
devDependencies: | devDependencies: | ||||
'@types/negotiator': | |||||
specifier: ^0.6.3 | |||||
version: 0.6.3 | |||||
'@types/node': | '@types/node': | ||||
specifier: ^20.11.0 | specifier: ^20.11.0 | ||||
version: 20.11.0 | version: 20.11.0 | ||||
@@ -759,6 +765,10 @@ packages: | |||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} | resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} | ||||
dev: true | dev: true | ||||
/@types/negotiator@0.6.3: | |||||
resolution: {integrity: sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==} | |||||
dev: true | |||||
/@types/node@20.11.0: | /@types/node@20.11.0: | ||||
resolution: {integrity: sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==} | resolution: {integrity: sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==} | ||||
dependencies: | dependencies: | ||||