diff --git a/src/backend/extenders/method.ts b/src/backend/extenders/method.ts deleted file mode 100644 index f22d162..0000000 --- a/src/backend/extenders/method.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {constants} from 'http2'; -import http from 'http'; -import {HttpMiddlewareError} from '../server'; - -interface RequestContext extends http.IncomingMessage { - method?: string; -} - -export const adjustMethod = (req: RequestContext) => { - if (!req.method) { - throw new HttpMiddlewareError('methodNotAllowed', { - statusCode: constants.HTTP_STATUS_METHOD_NOT_ALLOWED, - headers: { - 'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE', - }, - }); - } - - req.method = req.method.trim().toUpperCase(); -}; diff --git a/src/backend/extenders/url.ts b/src/backend/extenders/url.ts deleted file mode 100644 index 1558d7b..0000000 --- a/src/backend/extenders/url.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {constants} from 'http2'; -import http from 'http'; -import {HttpMiddlewareError} from '../server'; - -interface RequestContext extends http.IncomingMessage { - basePath?: string; - - query?: URLSearchParams; - - rawUrl?: string; -} - -export const adjustUrl = (req: RequestContext) => { - if (!req.url) { - throw new HttpMiddlewareError('badRequest', { - statusCode: constants.HTTP_STATUS_BAD_REQUEST, - }); - } - - const theBasePathUrl = req.basePath ?? ''; - const basePath = new URL(theBasePathUrl, 'http://localhost'); - const parsedUrl = new URL(`${theBasePathUrl}/${req.url}`, 'http://localhost'); - req.rawUrl = req.url; - req.url = req.url.slice(basePath.pathname.length); - req.query = parsedUrl.searchParams; - return; -}; diff --git a/src/backend/server.ts b/src/backend/server.ts index ebabe7d..35f5fc1 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -4,8 +4,6 @@ import {Language, Resource, Charset, MediaType, LanguageStatusMessageMap} from ' import https from 'https'; import Negotiator from 'negotiator'; import {constants} from 'http2'; -import {adjustMethod} from './extenders/method'; -import {adjustUrl} from './extenders/url'; import { handleCreateItem, handleDeleteItem, @@ -119,8 +117,13 @@ export interface Middleware { (req: Req): undefined | Response | Promise; } -const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { +const getAllowedMiddlewares = (resource?: Resource, mainResourceId = '') => { const middlewares = [] as [string, Middleware, v.BaseSchema?][]; + + if (typeof resource === 'undefined') { + return middlewares; + } + if (mainResourceId === '') { if (resource.state.canFetchCollection) { middlewares.push(['GET', handleGetCollection]); @@ -258,118 +261,58 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr req.host = serverParams.host ?? 'localhost'; req.scheme = isHttps ? 'https' : 'http'; req.cn = req.backend.cn; + req.method = req.method?.trim().toUpperCase() ?? ''; + + const theBasePathUrl = req.basePath ?? ''; + const basePath = new URL(theBasePathUrl, 'http://localhost'); + const parsedUrl = new URL(`${theBasePathUrl}/${req.url ?? ''}`, 'http://localhost'); + req.rawUrl = req.url; + req.url = req.url?.slice(basePath.pathname.length) ?? ''; + req.query = parsedUrl.searchParams; adjustRequestForContentNegotiation(req, res); - try { - adjustMethod(req); - } catch (errRaw) { - if (typeof errRaw !== 'undefined') { - const err= errRaw as HttpMiddlewareError; - const errBody = err.response.body; - if (typeof errBody !== 'undefined') { - res.writeHead(err.response.statusCode, { - ...(err.response.headers ?? {}), - 'Content-Language': req.backend.cn.language.name, - 'Content-Type': [ - req.backend.cn.mediaType.name, - `charset="${req.backend.cn.charset.name}"` - ].join('; '), - }); - res.statusMessage = err.response.statusMessage ?? ''; - const errBodySerialized = req.backend.cn.mediaType.serialize(errBody); - const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined; - res.end(errBodyEncoded); - return; - } - res.writeHead(err.response.statusCode, { - ...(err.response.headers ?? {}), - 'Content-Language': req.backend.cn.language.name, - }); - res.statusMessage = err.response.statusMessage ?? ''; - res.end(); - return; - } + let middlewareState; + if (req.url === '/' || req.url === '') { + middlewareState = await handleGetRoot(req); } - try { - adjustUrl(req); - } catch (errRaw) { - if (typeof errRaw !== 'undefined') { - const err= errRaw as HttpMiddlewareError; - const errBody = err.response.body; - if (typeof errBody !== 'undefined') { - res.writeHead(err.response.statusCode, { - ...(err.response.headers ?? {}), - 'Content-Language': req.backend.cn.language.name, - 'Content-Type': [ - req.backend.cn.mediaType.name, - `charset="${req.backend.cn.charset.name}"` - ].join('; '), - }); - res.statusMessage = err.response.statusMessage ?? ''; - const errBodySerialized = req.backend.cn.mediaType.serialize(errBody); - const errBodyEncoded = typeof errBodySerialized !== 'undefined' ? req.backend.cn.charset.encode(errBodySerialized) : undefined; - res.end(errBodyEncoded); - return; - } - res.writeHead(err.response.statusCode, { - ...(err.response.headers ?? {}), - 'Content-Language': req.backend.cn.language.name, - }); - res.statusMessage = err.response.statusMessage ?? ''; + if (typeof middlewareState === 'undefined') { + const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? []; + const resource = Array.from(req.backend.app.resources).find((r) => r.state!.routeName === resourceRouteName); + if (typeof resource === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); res.end(); return; } - } - if (req.url === '/') { - const middlewareState = await handleGetRoot(req); - if (typeof middlewareState !== 'undefined') { - return; - } + req.resource = resource as BackendResource; + req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; + req.resourceId = resourceId; - res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - Allow: 'HEAD, GET' - }); - res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); - res.end(); - return; - } - - const [, resourceRouteName, resourceId = ''] = req.url?.split('/') ?? []; - const resource = Array.from(req.backend.app.resources).find((r) => r.state!.routeName === resourceRouteName); - if (typeof resource === 'undefined') { - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); - res.end(); - return; - } - - req.resource = resource as BackendResource; - req.resource.dataSource = req.backend.dataSource(req.resource) as DataSource; - req.resourceId = resourceId; - - try { - await req.resource.dataSource.initialize(); - } catch (cause) { - throw new HttpMiddlewareError( - 'unableToInitializeResourceDataSource', - { - cause, - statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, - } - ); + try { + await req.resource.dataSource.initialize(); + } catch (cause) { + throw new HttpMiddlewareError( + 'unableToInitializeResourceDataSource', + { + cause, + statusCode: constants.HTTP_STATUS_INTERNAL_SERVER_ERROR, + } + ); + } } - const middlewares = getAllowedMiddlewares(resource, resourceId); - const middlewareState = await middlewares.reduce( + const middlewares = getAllowedMiddlewares(req.resource, req.resourceId ?? ''); + middlewareState = await middlewares.reduce( async (currentHandlerStatePromise, currentValue) => { const [middlewareMethod, middleware, schema] = currentValue; try { const currentHandlerState = await currentHandlerStatePromise; + const effectiveMethod = req.method === 'HEAD' ? 'GET' : req.method; - if (req.method !== middlewareMethod) { + if (effectiveMethod !== middlewareMethod) { return currentHandlerState; } @@ -401,6 +344,12 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr const result = await middleware(req); + if (req.method === 'HEAD' && result instanceof PlainResponse) { + const { body: _, ...etcResult } = result; + + return Promise.resolve(new PlainResponse(etcResult)); + } + return Promise.resolve(result); } catch (errRaw) { // todo use error message key for each method @@ -418,8 +367,8 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr return errRaw; } }, - Promise.resolve>(undefined) - ) as Awaited>; + Promise.resolve | HttpMiddlewareError>(middlewareState) + ) as Awaited | HttpMiddlewareError>; if (typeof middlewareState !== 'undefined') { try { @@ -474,8 +423,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr ].join('; '); } + const statusMessageFn = middlewareState.statusMessage ? req.cn.language.statusMessages[middlewareState.statusMessage] : undefined; + res.statusMessage = statusMessageFn?.(req.resource) ?? ''; res.writeHead(middlewareState.statusCode, headers); - res.statusMessage = middlewareState.statusMessage ?? ''; if (typeof encoded !== 'undefined') { res.end(encoded); return; @@ -508,8 +458,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr `charset=${req.backend.cn.charset.name}` ].join('; '); + const statusMessageFn = finalErr.response.statusMessage ? req.backend.cn.language.statusMessages[finalErr.response.statusMessage] : undefined; + res.statusMessage = statusMessageFn?.(req.resource) ?? ''; res.writeHead(finalErr.response.statusCode, headers); - res.statusMessage = finalErr.response.statusMessage ?? ''; if (typeof encoded !== 'undefined') { res.end(encoded); return; @@ -520,16 +471,17 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } if (middlewares.length > 0) { + res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { Allow: middlewares.map((m) => m[0]).join(', ') }); - res.statusMessage = req.backend.cn.language.statusMessages.methodNotAllowed(); res.end(); return; } - res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + // TODO error handler in line with authentication res.statusMessage = req.backend.cn.language.statusMessages.urlNotFound(); + res.writeHead(constants.HTTP_STATUS_NOT_FOUND); res.end(); return; }); diff --git a/test/e2e/default.test.ts b/test/e2e/default.test.ts index 7b01643..493301b 100644 --- a/test/e2e/default.test.ts +++ b/test/e2e/default.test.ts @@ -129,6 +129,11 @@ describe('yasumi', () => { describe('serving collections', () => { beforeEach(() => { Piano.canFetchCollection(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }); + }); }); afterEach(() => { @@ -154,6 +159,7 @@ describe('yasumi', () => { }); expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + // TODO test status messsages expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT)); let resBuffer = Buffer.from(''); @@ -177,6 +183,37 @@ describe('yasumi', () => { req.end(); }); }); + + it('returns data on HEAD method', () => { + return new Promise((resolve, reject) => { + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos', + method: 'HEAD', + headers: { + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, + 'Accept-Language': 'en', + }, + }, + (res) => { + res.on('error', (err) => { + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + resolve(); + }, + ); + + req.on('error', (err) => { + reject(err); + }); + + req.end(); + }); + }); }); describe('serving items', () => { @@ -192,6 +229,11 @@ describe('yasumi', () => { beforeEach(() => { Piano.canFetchItem(); + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }); + }); }); afterEach(() => { @@ -241,6 +283,37 @@ describe('yasumi', () => { }); }); + it('returns data on HEAD method', () => { + return new Promise((resolve, reject) => { + // TODO all responses should have serialized ids + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/1', + method: 'HEAD', + headers: { + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, + }, + }, + (res) => { + res.on('error', (err) => { + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK); + resolve(); + }, + ); + + req.on('error', (err) => { + reject(err); + }); + + req.end(); + }); + }); + it('throws on item not found', () => { return new Promise((resolve, reject) => { const req = request( @@ -271,6 +344,37 @@ describe('yasumi', () => { req.end(); }); }); + + it('throws on item not found on HEAD method', () => { + return new Promise((resolve, reject) => { + const req = request( + { + host: HOST, + port: PORT, + path: '/api/pianos/2', + method: 'HEAD', + headers: { + 'Accept': `${ACCEPT}; charset="${ACCEPT_CHARSET}"`, + }, + }, + (res) => { + res.on('error', (err) => { + Piano.canFetchItem(false); + reject(err); + }); + + expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND); + resolve(); + }, + ); + + req.on('error', (err) => { + reject(err); + }); + + req.end(); + }); + }); }); describe('creating items', () => {