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)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue