Adding some routers to http server that handle more requests to /reservations and /cron

This commit is contained in:
Collin Duncan 2023-01-30 12:39:05 +01:00
parent 6dfa37272e
commit 921aa9c65c
No known key found for this signature in database
6 changed files with 247 additions and 102 deletions

View file

@ -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<string, unknown>
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

45
src/server/http/index.ts Normal file
View file

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

View file

@ -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<IncomingMessage>,
) {
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<IncomingMessage>,
) {
l.getStore()?.debug('Enabling cron')
startTasks()
res.writeHead(200)
}
private async POST_cron_disable(
_req: IncomingMessage,
res: ServerResponse<IncomingMessage>,
) {
l.getStore()?.debug('Disabling cron')
stopTasks()
res.writeHead(200)
}
}

View file

@ -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<string, unknown>
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<IncomingMessage>) {
const { url, method } = req
asyncLocalStorage.getStore()?.info('Not found', { url, method })
res.writeHead(404, 'Not found')
}
public abstract handleRequest(
req: IncomingMessage,
res: ServerResponse<IncomingMessage>,
): Promise<void>
}
export class RouterError extends LoggableError {}
export class RouterBadRequestError extends RouterError {}
export class RouterUnsupportedContentTypeError extends RouterError {}

View file

@ -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<IncomingMessage>,
) {
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<IncomingMessage>) {
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<void>((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<IncomingMessage>) {
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<IncomingMessage>) {
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)
}
}
}

View file

@ -36,13 +36,16 @@ describe('server', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
}) })
test('should reject non-POST request', async () => { test('should accept POST to /cron', async () => {
jest jest
.spyOn(scheduler, 'schedule') .spyOn(scheduler, 'schedule')
.mockImplementationOnce(() => Promise.resolve({})) .mockImplementationOnce(() => Promise.resolve({}))
await expect(() => axios.get(`${baseUrl}/reservations`)).rejects.toThrow( const response = await axios.post(
axios.AxiosError `${baseUrl}/cron/disable`,
{},
{ headers: { 'content-type': 'application/json' } }
) )
expect(response.status).toBe(200)
}) })
test('should reject request to other route', async () => { test('should reject request to other route', async () => {
@ -53,41 +56,4 @@ describe('server', () => {
axios.AxiosError 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)
})
}) })