Skip to content

Commit 789166d

Browse files
authored
refactor(backend): Split monolithic router into separate controllers (#168)
* Refactor router into separate controllers * Fix extensions in imports * Fix timeout when handler does not respond * Add controller unit test
1 parent d6fd7ae commit 789166d

11 files changed

+988
-596
lines changed

package-lock.json

+272-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@types/express": "^5.0.0",
5858
"@types/jasmine": "~5.1.0",
5959
"@types/jsonwebtoken": "^9.0.8",
60+
"@types/supertest": "^6.0.2",
6061
"angular-eslint": "19.0.0",
6162
"autoprefixer": "^10.4.20",
6263
"eslint": "^9.15.0",
@@ -78,6 +79,7 @@
7879
"prettier-plugin-organize-imports": "^4.1.0",
7980
"prettier-plugin-packagejson": "^2.5.6",
8081
"prettier-plugin-tailwindcss": "^0.6.9",
82+
"supertest": "^7.0.0",
8183
"tailwindcss": "^3.4.16",
8284
"tsc-alias": "^1.8.10",
8385
"typescript": "~5.6.2",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { appendDir } from '@common/endpoint-utils.js'
2+
import { Benchmark, BenchmarkPreview } from '@common/interfaces.js'
3+
import { StripDir } from '@common/utility-types.js'
4+
import { configuration } from '../config/config.mjs'
5+
import { SqlService } from '../services/sql-service.mjs'
6+
import { Controller, GetHandler } from './controller.mjs'
7+
8+
export class BenchmarkController extends Controller {
9+
constructor(config: configuration) {
10+
super(config)
11+
12+
this.attachGet('/benchmarks', this.getBenchmarks)
13+
this.attachGet('/benchmarks/:id', this.getBenchmarkById)
14+
}
15+
16+
getBenchmarks: GetHandler<'/benchmarks'> = async (req, res) => {
17+
const sql = SqlService.getInstance()
18+
const rows = await sql.query<StripDir<BenchmarkPreview>>`
19+
SELECT id, name, description FROM benchmarks
20+
ORDER BY id ASC
21+
`
22+
const resources = appendDir('/benchmarks/', rows)
23+
this.respond(res, resources)
24+
}
25+
26+
getBenchmarkById: GetHandler<'/benchmarks/:id'> = async (req, res) => {
27+
const ids = req.params.id.split(',').map((s) => +s)
28+
const sql = SqlService.getInstance()
29+
// id=ANY - dev.003
30+
const rows = await sql.query<StripDir<Benchmark>>`
31+
SELECT * FROM benchmarks
32+
WHERE id=ANY(${ids})
33+
LIMIT ${ids.length}
34+
`
35+
const benchmarks = appendDir('/benchmarks/', rows)
36+
this.respond(res, benchmarks)
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { ApiEndpointsOfVerb, ApiGetEndpoints, ApiPatchEndpoints, ApiPostEndpoints } from '@common/api-endpoints.js'
2+
import { ApiResponse } from '@common/api-response.js'
3+
import express, { NextFunction, Request, Response, Router } from 'express'
4+
import type { RouteParameters } from 'express-serve-static-core'
5+
import { configuration } from '../config/config.mjs'
6+
7+
/**
8+
* Type for handlers for given method.
9+
*/
10+
export type HandlerOfVerb<V extends string, E extends keyof ApiEndpointsOfVerb<V>> = (
11+
req: Request<
12+
// allows inferring ":parameters"
13+
RouteParameters<E>,
14+
// unknown, due to chaining
15+
unknown,
16+
// type of request body and query taken from ApiEndpointDefinitions
17+
ApiEndpointsOfVerb<V>[E]['request']['body'],
18+
ApiEndpointsOfVerb<V>[E]['request']['query']
19+
>,
20+
res: Response<ApiEndpointsOfVerb<V>[E]['response']>,
21+
next: NextFunction,
22+
) => void | Promise<void>
23+
24+
/**
25+
* Type for GET handlers.
26+
*/
27+
export type GetHandler<E extends keyof ApiGetEndpoints> = HandlerOfVerb<'GET', E>
28+
29+
/**
30+
* Type for POST handlers.
31+
*/
32+
export type PostHandler<E extends keyof ApiPostEndpoints> = HandlerOfVerb<'POST', E>
33+
34+
/**
35+
* Type for PATCH handlers.
36+
*/
37+
export type PatchHandler<E extends keyof ApiPatchEndpoints> = HandlerOfVerb<'PATCH', E>
38+
39+
/**
40+
* Base class for controllers.
41+
* In your child class, attach handlers using `attach<Verb>` methods in the
42+
* constructor.
43+
*/
44+
export class Controller {
45+
/** Router object to pass in `express.use` */
46+
router: Router
47+
48+
constructor(public config: configuration) {
49+
this.router = express.Router()
50+
}
51+
52+
/**
53+
* Send a well-typed JSON response with optional debug info.
54+
* @param res Express response.
55+
* @param body Response body. Type is derived from endpoint registry.
56+
* @param dbg Additional debug info.
57+
* @see {@link ApiResponse}
58+
*/
59+
respond<T>(res: Response<ApiResponse<T>>, body: T, dbg?: unknown) {
60+
res.json({
61+
body: body,
62+
dbg,
63+
})
64+
}
65+
66+
/**
67+
* Send a well-typed error with code 400 (Bad request), additional error text
68+
* and optional debug info.
69+
* @param res Express response.
70+
* @param error Error object.
71+
* @param dbg Additional debug info.
72+
* @see {@link ApiResponse}
73+
*/
74+
requestError<T>(res: Response<ApiResponse<T>>, error: ApiResponse<T>['error'], dbg?: unknown) {
75+
res.status(400)
76+
res.json({
77+
error,
78+
dbg,
79+
})
80+
}
81+
82+
/**
83+
* Send a well-typed error with code 401 (Unauthorized), additional
84+
* error text and optional debug info.
85+
* @param res Express response.
86+
* @param error Error object.
87+
* @param dbg Additional debug info.
88+
* @see {@link ApiResponse}
89+
*/
90+
unauthorizedError<T>(res: Response<ApiResponse<T>>, error: ApiResponse<T>['error'], dbg?: unknown) {
91+
res.status(401)
92+
res.json({
93+
error,
94+
dbg,
95+
})
96+
}
97+
98+
/**
99+
* Send a well-typed error with code 500 (Internal server error), additional
100+
* error text and optional debug info.
101+
* @param res Express response.
102+
* @param error Error object.
103+
* @param dbg Additional debug info.
104+
* @see {@link ApiResponse}
105+
*/
106+
serverError<T>(res: Response<ApiResponse<T>>, error: ApiResponse<T>['error'], dbg?: unknown) {
107+
res.status(500)
108+
res.json({
109+
error,
110+
dbg,
111+
})
112+
}
113+
114+
// Attach handler wrapper, serves two purposes:
115+
// 1. Provides a common try catch wrap around the handler.
116+
// 2. Allows type inferring from route (Express' types are too convoluted to
117+
// be overriden by declares IMHO).
118+
private attach<V extends 'get' | 'post' | 'patch', E extends keyof ApiEndpointsOfVerb<Uppercase<V>>>(
119+
verb: V,
120+
endpoint: E,
121+
handler: HandlerOfVerb<Uppercase<V>, E>,
122+
) {
123+
this.router[verb](endpoint, async (req, res, next) => {
124+
try {
125+
await handler(req, res, next)
126+
// force a server error if the handler did not respond
127+
if (!res.writableEnded) {
128+
next('Handler did not respond')
129+
}
130+
} catch (error) {
131+
next(error)
132+
}
133+
})
134+
}
135+
136+
/**
137+
* Attach handler for GET endpoint.
138+
*/
139+
attachGet<E extends keyof ApiGetEndpoints>(endpoint: E, handler: GetHandler<E>) {
140+
this.attach('get', endpoint, handler)
141+
}
142+
143+
/**
144+
* Attach handler for POST endpoint.
145+
*/
146+
attachPost<E extends keyof ApiPostEndpoints>(endpoint: E, handler: PostHandler<E>) {
147+
this.attach('post', endpoint, handler)
148+
}
149+
150+
/**
151+
* Attach handler for PATCH endpoint.
152+
*/
153+
attachPatch<E extends keyof ApiPatchEndpoints>(endpoint: E, handler: PatchHandler<E>) {
154+
this.attach('patch', endpoint, handler)
155+
}
156+
}
157+
158+
/**
159+
* Returns a short recap of requested endpoint (method + url) for logging.
160+
*/
161+
export function dbgRequestEndpoint(req: Request) {
162+
return `${req.method} ${req.url}`
163+
}
164+
165+
/**
166+
* Returns an object based off Request reduced to the valuable information.
167+
*/
168+
export function dbgRequestObject(req: Request) {
169+
return {
170+
// full request URL (e.g. /mirror/1?test=2&tester=3)
171+
url: req.url,
172+
// query params (e.g. {test: 2, tester: 3})
173+
query: req.query,
174+
// path (e.g. /mirror/1)
175+
path: req.path,
176+
// params (e.g. {id: 1})
177+
params: req.params,
178+
// body (JSON, e.g. when POST requesting)
179+
body: req.body,
180+
}
181+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Express } from 'express'
2+
import express from 'express'
3+
import supertest from 'supertest'
4+
import TestAgent from 'supertest/lib/agent'
5+
import { beforeAll, describe, expect, test } from 'vitest'
6+
import { defaults } from '../config/defaults.mjs'
7+
import { Controller } from './controller.mjs'
8+
9+
describe.sequential('Controller', () => {
10+
let app: Express
11+
let request: TestAgent
12+
let controller: Controller
13+
14+
beforeAll(() => {
15+
app = express()
16+
request = supertest(app)
17+
})
18+
19+
test('should be instantiated (with default configuration)', () => {
20+
controller = new Controller(defaults)
21+
expect(controller.router).toBeTruthy()
22+
app.use(controller.router)
23+
})
24+
25+
// All `attach<Verb>` methods are aliases for the private `attach`. Hence let
26+
// us assume that by testing one (i.e. `attachGet`), all of them are tested
27+
// sufficiently.
28+
test('should be able to attach handlers', () => {
29+
// can't attach to undefined endpoints, but can trick TS into see them as defined
30+
controller.attachGet('/test-get' as '/mirror', (req, res) => {
31+
controller.respond(res, '<ok>', '<debug>')
32+
})
33+
controller.attachGet('/test-request-error' as '/mirror', (req, res) => {
34+
controller.requestError(res, { text: 'request error' })
35+
})
36+
controller.attachGet('/test-auth-error' as '/mirror', (req, res) => {
37+
controller.unauthorizedError(res, { text: 'auth error' })
38+
})
39+
controller.attachGet('/test-server-error' as '/mirror', (req, res) => {
40+
controller.serverError(res, { text: 'server error' })
41+
})
42+
// test fallback error handler with exception
43+
controller.attachGet('/test-catch' as '/mirror', (_req, _res) => {
44+
throw 'error'
45+
})
46+
// test fallback error handler with undefined handler response
47+
controller.attachGet('/test-undefined' as '/mirror', (_req, _res) => {
48+
/* */
49+
})
50+
// test for routes presence
51+
const routes = getControllerRoutes(controller)
52+
expect(routes).toContain('/test-get')
53+
expect(routes).toContain('/test-request-error')
54+
expect(routes).toContain('/test-auth-error')
55+
expect(routes).toContain('/test-server-error')
56+
expect(routes).toContain('/test-catch')
57+
expect(routes).toContain('/test-undefined')
58+
// no more than the the explicitely attached routes should be present
59+
expect(routes).toHaveLength(6)
60+
})
61+
62+
test.each([
63+
{
64+
route: '/test-get',
65+
expects: { status: 200, response: { body: '<ok>', dbg: '<debug>' } },
66+
},
67+
{
68+
route: '/test-request-error',
69+
expects: { status: 400, response: { error: { text: 'request error' } } },
70+
},
71+
{
72+
route: '/test-auth-error',
73+
expects: { status: 401, response: { error: { text: 'auth error' } } },
74+
},
75+
{
76+
route: '/test-server-error',
77+
expects: { status: 500, response: { error: { text: 'server error' } } },
78+
},
79+
// fallback error handler does not set a response body
80+
{
81+
route: '/test-catch',
82+
expects: { status: 500, response: {} },
83+
},
84+
{
85+
route: '/test-undefined',
86+
expects: { status: 500, response: {} },
87+
},
88+
])('should answer $route with $expects', async (testCase) => {
89+
const res = await request.get(testCase.route)
90+
expect(res.statusCode).toBe(testCase.expects.status)
91+
if (typeof testCase.expects.response !== 'undefined') {
92+
expect(res.body).toEqual(testCase.expects.response)
93+
}
94+
})
95+
})
96+
97+
function getControllerRoutes(controller: Controller): string[] {
98+
return controller.router.stack.filter((r) => r.route).map((r) => r.route!.path)
99+
}

0 commit comments

Comments
 (0)