From 921aa9c65cbca38fc7bfe3fe43707466372c5f9f Mon Sep 17 00:00:00 2001 From: Collin Duncan <3679940+cgduncan7@users.noreply.github.com> Date: Mon, 30 Jan 2023 12:39:05 +0100 Subject: [PATCH] Adding some routers to http server that handle more requests to /reservations and /cron --- src/server/http.ts | 62 --------------- src/server/http/index.ts | 45 +++++++++++ src/server/http/routes/cron.ts | 45 +++++++++++ src/server/http/routes/index.ts | 48 ++++++++++++ src/server/http/routes/reservations.ts | 103 +++++++++++++++++++++++++ tests/unit/server/http.test.ts | 46 ++--------- 6 files changed, 247 insertions(+), 102 deletions(-) delete mode 100644 src/server/http.ts create mode 100644 src/server/http/index.ts create mode 100644 src/server/http/routes/cron.ts create mode 100644 src/server/http/routes/index.ts create mode 100644 src/server/http/routes/reservations.ts diff --git a/src/server/http.ts b/src/server/http.ts deleted file mode 100644 index ed3f298..0000000 --- a/src/server/http.ts +++ /dev/null @@ -1,62 +0,0 @@ -import http from 'http' -import { v4 } from 'uuid' -import { asyncLocalStorage, Logger, LogLevel } from '../common/logger' -import { schedule } from '../common/scheduler' -import { parseJson } from './utils' - -// Handles POST requests to /reservations -const server = http.createServer(async (req, res) => { - await asyncLocalStorage.run( - new Logger('request', v4(), LogLevel.DEBUG), - async () => { - const logger = asyncLocalStorage.getStore() - const { url, method } = req - logger?.debug('Incoming request', { url, method }) - - if ( - !url || - !method || - !/^\/reservations$/.test(url) || - method.toLowerCase() !== 'post' - ) { - logger?.info('Not found', { url, method }) - res.writeHead(404, 'Not found') - res.end() - return - } - - let jsonBody: Record - const contentType = req.headers['content-type'] || 'application/json' - if (contentType !== 'application/json') { - logger?.error('Invalid content type', { contentType }) - res.writeHead(406, 'Unsupported content type') - res.end() - return - } - - try { - const length = Number.parseInt(req.headers['content-length'] || '0') - const encoding = req.readableEncoding || 'utf8' - jsonBody = await parseJson(length, encoding, req) - } catch (error: any) { - logger?.error('Failed to parse body', { error: error.message }) - res.writeHead(400, 'Bad request') - res.end() - return - } - - try { - await schedule(jsonBody) - } catch (error: any) { - logger?.error('Failed to schedule request', { error }) - res.writeHead(400, 'Bad request') - res.end() - return - } - - res.end() - } - ) -}) - -export default server diff --git a/src/server/http/index.ts b/src/server/http/index.ts new file mode 100644 index 0000000..aa72756 --- /dev/null +++ b/src/server/http/index.ts @@ -0,0 +1,45 @@ +import http from 'http' +import { v4 } from 'uuid' +import { asyncLocalStorage, Logger, LogLevel } from '../../common/logger' +import { CronRouter } from './routes/cron' +import { ReservationsRouter } from './routes/reservations' + +const cronRouter = new CronRouter() +const reservationsRouter = new ReservationsRouter() + +// Handles POST requests to /reservations +const server = http.createServer(async (req, res) => { + await asyncLocalStorage.run( + new Logger('request', v4(), LogLevel.DEBUG), + async () => { + const logger = asyncLocalStorage.getStore() + const { url, method } = req + logger?.debug('Incoming request', { url, method }) + + if (!url || !method) { + logger?.info('Weird request', { url, method }) + res.writeHead(400, 'Bad request') + res.end() + return + } + + switch (true) { + case /^\/cron/.test(url): { + await cronRouter.handleRequest(req, res) + break + } + case /^\/reservations/.test(url): { + await reservationsRouter.handleRequest(req, res) + break + } + default: { + logger?.info('Not found', { url, method, location: 'root' }) + res.writeHead(404, 'Not found') + } + } + res.end() + } + ) +}) + +export default server diff --git a/src/server/http/routes/cron.ts b/src/server/http/routes/cron.ts new file mode 100644 index 0000000..1a869c2 --- /dev/null +++ b/src/server/http/routes/cron.ts @@ -0,0 +1,45 @@ +import { IncomingMessage, ServerResponse } from 'http' +import { asyncLocalStorage as l } from '../../../common/logger' +import { Router } from './index' +import { startTasks, stopTasks } from '../../cron' + +export class CronRouter extends Router { + public async handleRequest( + req: IncomingMessage, + res: ServerResponse, + ) { + const { url = '', method } = req + const [route] = url.split('?') + switch (true) { + case /^\/cron\/enable$/.test(route) && method === 'POST': { + await this.POST_cron_enable(req, res) + break + } + case /^\/cron\/disable$/.test(route) && method === 'POST': { + await this.POST_cron_disable(req, res) + break + } + default: { + this.handle404(req, res) + } + } + } + + private async POST_cron_enable( + _req: IncomingMessage, + res: ServerResponse, + ) { + l.getStore()?.debug('Enabling cron') + startTasks() + res.writeHead(200) + } + + private async POST_cron_disable( + _req: IncomingMessage, + res: ServerResponse, + ) { + l.getStore()?.debug('Disabling cron') + stopTasks() + res.writeHead(200) + } +} diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts new file mode 100644 index 0000000..aef973c --- /dev/null +++ b/src/server/http/routes/index.ts @@ -0,0 +1,48 @@ +import { IncomingMessage, ServerResponse } from 'http' +import { asyncLocalStorage, asyncLocalStorage as l, LoggableError } from '../../../common/logger' +import { parseJson } from '../../utils' + +export abstract class Router { + protected async parseJsonContent( + req: IncomingMessage, + ) { + let jsonBody: Record + const contentType = + req.headers['content-type'] || 'application/json' + if (contentType !== 'application/json') { + l.getStore()?.error('Invalid content type', { contentType }) + throw new RouterUnsupportedContentTypeError() + } + + try { + const length = Number.parseInt( + req.headers['content-length'] || '0' + ) + const encoding = req.readableEncoding || 'utf8' + jsonBody = await parseJson(length, encoding, req) + } catch (error: unknown) { + l.getStore()?.error('Failed to parse body', { + error: (error as Error).message, + stack: (error as Error).stack, + }) + throw new RouterBadRequestError() + } + + return jsonBody + } + + protected handle404(req: IncomingMessage, res: ServerResponse) { + const { url, method } = req + asyncLocalStorage.getStore()?.info('Not found', { url, method }) + res.writeHead(404, 'Not found') + } + + public abstract handleRequest( + req: IncomingMessage, + res: ServerResponse, + ): Promise +} + +export class RouterError extends LoggableError {} +export class RouterBadRequestError extends RouterError {} +export class RouterUnsupportedContentTypeError extends RouterError {} \ No newline at end of file diff --git a/src/server/http/routes/reservations.ts b/src/server/http/routes/reservations.ts new file mode 100644 index 0000000..bff5638 --- /dev/null +++ b/src/server/http/routes/reservations.ts @@ -0,0 +1,103 @@ +import { IncomingMessage, ServerResponse } from 'http' +import { schedule } from '../../../common/scheduler' +import { asyncLocalStorage as l } from '../../../common/logger' +import { Router } from './index' +import { Reservation } from '../../../common/reservation' +import { parse } from 'querystring' + +export class ReservationsRouter extends Router { + public async handleRequest( + req: IncomingMessage, + res: ServerResponse, + ) { + const { url = '', method } = req + const [route] = url.split('?') + switch (true) { + case /^\/reservations$/.test(route) && method === 'GET': { + await this.GET_reservations(req, res) + break + } + case /^\/reservations$/.test(route) && method === 'POST': { + await this.POST_reservations(req, res) + break + } + case /^\/reservations\/\S+$/.test(route) && method === 'DELETE': { + await this.DELETE_reservation(req, res) + break + } + default: { + this.handle404(req, res) + } + } + } + + private async GET_reservations(req: IncomingMessage, res: ServerResponse) { + const { url = '' } = req + const [, queryParams] = url.split('?') + let pageNumber = 0 + let pageSize = 0 + const { pageNumber: rawPageNumber = '0', pageSize: rawPageSize = '50' } = parse(queryParams) + if (typeof rawPageNumber === 'string') { + pageNumber = Number.parseInt(rawPageNumber) + } else { + pageNumber = 0 + } + + if (typeof rawPageSize === 'string') { + pageSize = Math.min(Number.parseInt(rawPageSize), 50) + } else { + pageSize = 50 + } + + l.getStore()?.debug('Fetching reservations', { pageNumber, pageSize }) + + try { + const reservations = await Reservation.fetchByPage(pageNumber, pageSize) + res.setHeader('content-type', 'application/json') + l.getStore()?.debug('Found reservations', { reservations: reservations.map((r) => r.toString(true)) }) + return new Promise((resolve, reject) => { + res.write(`[${reservations.map((r) => r.toString(true)).join(',')}]`, (err) => { + if (err) { + reject(err) + } + resolve() + }) + }) + } catch (error) { + l.getStore()?.error('Failed to get reservations', { + message: (error as Error).message, + stack: (error as Error).stack, + }) + res.writeHead(500) + } + } + + private async POST_reservations(req: IncomingMessage, res: ServerResponse) { + const jsonBody = await this.parseJsonContent(req) + try { + await schedule(jsonBody) + } catch (error: unknown) { + l.getStore()?.error('Failed to create reservation', { + message: (error as Error).message, + stack: (error as Error).stack, + }) + res.writeHead(400) + } + } + + private async DELETE_reservation(req: IncomingMessage, res: ServerResponse) { + const { url = '' } = req + const [,,id] = url.split('/') + l.getStore()?.debug('Deleting reservation', { id }) + try { + await Reservation.deleteById(id) + res.writeHead(200) + } catch (error) { + l.getStore()?.error('Failed to get reservations', { + message: (error as Error).message, + stack: (error as Error).stack, + }) + res.writeHead(500) + } + } +} diff --git a/tests/unit/server/http.test.ts b/tests/unit/server/http.test.ts index 5a9d381..c6765b1 100644 --- a/tests/unit/server/http.test.ts +++ b/tests/unit/server/http.test.ts @@ -36,13 +36,16 @@ describe('server', () => { expect(response.status).toBe(200) }) - test('should reject non-POST request', async () => { + test('should accept POST to /cron', async () => { jest .spyOn(scheduler, 'schedule') .mockImplementationOnce(() => Promise.resolve({})) - await expect(() => axios.get(`${baseUrl}/reservations`)).rejects.toThrow( - axios.AxiosError + const response = await axios.post( + `${baseUrl}/cron/disable`, + {}, + { headers: { 'content-type': 'application/json' } } ) + expect(response.status).toBe(200) }) test('should reject request to other route', async () => { @@ -53,41 +56,4 @@ describe('server', () => { axios.AxiosError ) }) - - test('should reject request without content-type of json', async () => { - jest - .spyOn(scheduler, 'schedule') - .mockImplementationOnce(() => Promise.resolve({})) - await expect(() => - axios.post(`${baseUrl}/reservations`, 'test,123', { - headers: { 'content-type': 'text/csv' }, - }) - ).rejects.toThrow(axios.AxiosError) - }) - - test('should reject request if body cannot be parsed', async () => { - jest.spyOn(utils, 'parseJson').mockImplementationOnce(Promise.reject) - await expect(() => - axios.post( - `${baseUrl}/reservations`, - {}, - { - headers: { 'content-type': 'application/json' }, - } - ) - ).rejects.toThrow(axios.AxiosError) - }) - - test('should reject request if schedule cannot be performed', async () => { - jest.spyOn(scheduler, 'schedule').mockImplementationOnce(Promise.reject) - await expect(() => - axios.post( - `${baseUrl}/reservations`, - {}, - { - headers: { 'content-type': 'application/json' }, - } - ) - ).rejects.toThrow(axios.AxiosError) - }) })