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