Created
June 7, 2025 12:11
-
-
Save jmjf/e6434b716a94ec7451dbbb9188bb687c to your computer and use it in GitHub Desktop.
Testing ideas for request handler/controller separation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Testing ideas for request handler/controller separation | |
| // Simulate framework | |
| type RawRequest = { | |
| headers: unknown; | |
| query: unknown; | |
| body: unknown; | |
| } | |
| type RawReply = { | |
| setType: (resType: string) => void; | |
| setCode: (resCode: number) => void; | |
| send: (body: any) => void | |
| } | |
| const reply = { | |
| setType: (resType: string) => console.log('resType', resType), | |
| setCode: (resCode: number) => console.log('resCode', resCode), | |
| send: (body: any) => console.log('body', body), | |
| } | |
| // Common core code | |
| type RequestParts = { | |
| headers?: unknown; | |
| query?: unknown; | |
| body?: unknown; | |
| } | |
| abstract class Controller { | |
| protected abstract execImpl(opts: RequestParts): {resCode: number, resType: string, resBody: unknown}; | |
| public execute(req: RequestParts, reply: RawReply): unknown { | |
| const {resCode, resBody, resType} = this.execImpl({ headers: req.headers, query: req.query, body: req.body}); | |
| reply.setType(resType); | |
| reply.setCode(resCode); | |
| return reply.send(resBody) | |
| } | |
| // could build methods like replyOk, replyAccepted, etc. for different response codes and types | |
| // but that would require passing reply or maybe putting it in async context and extracting it | |
| } | |
| // Implementation for a specific controller | |
| type RealQuery = { | |
| userId?: string, | |
| petId?: string, | |
| } | |
| class TestController extends Controller { | |
| protected execImpl(opts: { query: RealQuery }) { | |
| const { query } = opts; | |
| const dto = { | |
| customerId: query.userId ?? '' | |
| } | |
| const result = testUC(dto); | |
| return { resCode: 200, resType: 'text/string', resBody: result.value} | |
| } | |
| } | |
| // Implementation for a specific UC | |
| type DTOType = { | |
| customerId: string; | |
| } | |
| function testUC(dto: DTOType) { | |
| console.log('UC customerId', dto.customerId); | |
| return { value: {msg: 'hello', petCount: 1, petTypes: ['dog']} } | |
| } | |
| // Route handler (can make this generic for all route handlers and generate with a factory) | |
| const ctrl = new TestController(); | |
| function handler(req: RawRequest, reply: RawReply) { | |
| return ctrl.execute({headers: req.headers, query: req.query, body: req.body}, reply) | |
| } | |
| // simulate framework calling the route handler | |
| handler({headers: {Authorization: 'blah', 'x-special-header': 'something'}, query: {userId: 'fred', something: 'blah'}, body: {}}, reply) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Syntactically, this works. Code runs in TypeBox playground with the following output, as expected.
This approach lets the controller implementation (
TestController) type expected and needed inputs in the function signature, avoidingconst query = req.query as ExpectedTypetype assignments. It assumes requests are processed with some kind of validator to ensure they comply with a schema/spec so the controller implementation doesn't need to validate.If the request handler adds
replyto an async context, helper methods on the abstractControllercould get that reply and use it to set status code, response type, etc., if needed. Or it could havesetStatusCodeandsetContentTypemethods that accept values from a list of valid values. (Declare a const object with values and use it.) Or maybe it's better to require those values as returns fromexecImpl, as shown here, so it's harder to forget them.This approach lets us write a controller, with all the real handler logic, in a way that doesn't care about the framework. To switch between Fastify, Express, raw Node HTTPServer, or any other framework that serves a request and reply with expected parts to a request handler, you just need a different route handler. Since route handlers are generic, they can come from a factory function that takes the controller they need to call.