Browse Source

Streamline backend server

Ensure responses have localizable messages.
master
TheoryOfNekomata 8 months ago
parent
commit
65d2cebd33
4 changed files with 160 additions and 151 deletions
  1. +0
    -20
      src/backend/extenders/method.ts
  2. +0
    -27
      src/backend/extenders/url.ts
  3. +56
    -104
      src/backend/server.ts
  4. +104
    -0
      test/e2e/default.test.ts

+ 0
- 20
src/backend/extenders/method.ts View File

@@ -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();
};

+ 0
- 27
src/backend/extenders/url.ts View File

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

+ 56
- 104
src/backend/server.ts View File

@@ -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 extends RequestContext = RequestContext> {
(req: Req): undefined | Response | Promise<undefined | Response>;
}

const getAllowedMiddlewares = <T extends v.BaseSchema>(resource: Resource<T>, mainResourceId: string) => {
const getAllowedMiddlewares = <T extends v.BaseSchema>(resource?: Resource<T>, 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<unknown>(
const middlewares = getAllowedMiddlewares(req.resource, req.resourceId ?? '');
middlewareState = await middlewares.reduce<unknown>(
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<ReturnType<Middleware>>(undefined)
) as Awaited<ReturnType<Middleware>>;
Promise.resolve<ReturnType<Middleware> | HttpMiddlewareError>(middlewareState)
) as Awaited<ReturnType<Middleware> | 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;
});


+ 104
- 0
test/e2e/default.test.ts View File

@@ -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<void>((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<void>((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<void>((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<void>((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', () => {


Loading…
Cancel
Save