Adding some routers to http server that handle more requests to /reservations and /cron
This commit is contained in:
parent
6dfa37272e
commit
921aa9c65c
6 changed files with 247 additions and 102 deletions
|
|
@ -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
45
src/server/http/index.ts
Normal 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
|
||||||
45
src/server/http/routes/cron.ts
Normal file
45
src/server/http/routes/cron.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/server/http/routes/index.ts
Normal file
48
src/server/http/routes/index.ts
Normal 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 {}
|
||||||
103
src/server/http/routes/reservations.ts
Normal file
103
src/server/http/routes/reservations.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue