Now that we have a basic Koa server running an RxJS reactive pipeline for each request from building our core server, we’re in a good place to consider how a route handler should process each request: what inputs and outputs are necessary?
Solidifying a design for the server/application interface
To start with, using the Koa
Context directly in application route handlers isn’t ideal — it’s a low level interface that an application shouldn’t generally be concerned with.
Each application route should have a route matcher that does the following:
- Receive information about the request.
- Determine if the request should be handled by its corresponding route handler based on information it extracts from the request.
- Return extracted information from the request to be consumed by its route handler.
Each route will also have a route handler that should:
- Receive extracted information about the request.
- Return a structured response.
The server will call into the route matcher and handler for each route as necessary, processing the result and writing it to the final response via Koa’s
The response API is brand new; the core implementation had the route handler write directly to the Koa
Context. Working backwards from this will make it clear what adjustments are needed in the router, so I’ll start by defining this API:
We now have a straightforward language of types to use when thinking about responses. A
Response is either a successful
JsonResponse with a custom body for a given route, or an
ErrorResponse with a standard message field. By keeping the
body properties of a given response the exact shape we want a user to receive, we make it easy for the layer that will handle these responses to do so with minimal overhead or knowledge of different response types. Additionally, we provide standard functions for application routes to use when constructing responses.
Response infrastructure in place, it’s time to modify the router to use these in
router.ts. If you recall the design above, the handler will also need to receive route params from the matcher. We’ll start by udpating the type contract through which the two application-defined route hooks interact.
We can update
RouteHandler to be a generic of the params it receives and its response body type. Whatever the body type is will be wrapped in an
Observable<Response>. We’ll also update the
RoutePredicate type to be
RouteMatcher, which returns a generic of matched params or a falsey indication that it’s not a match for a given route. The updated types are as follows:
Updating the router’s vended utilities, we’ll provide matchers a
match() function to indicate positive matches and wrap their parsed params. Although we could make them simply return the raw params, wrapping them in an object makes the
Match<P> | false | undefined type safe to work with and assume falsey values are non-matches to reason about without needing to use generic type constraints. This will allow routes with no route params to return an empty
match(undefined) without tripping up the router’s matching logic.
We’ll also update
route() with the necessary generic typing for the generic matcher and handler.
With the above in place, we can update the
handleRoute() function to manage passing any params for a matched route into the matched route handler. If no routes match, we’ll return an
Observable with a single
NotFoundResponse built with
Additionally, we’ll make the router do the work of emitting a response to the Koa
// router.ts;;;;// ... updated types and utilities omitted ...;;
Routes and server
Only a few small adjustments remain for us to tie everything together.
- Update the array to reflect the generic
export const routes: Route<any, any> = [getGreeting];
server.ts needs minimal modifications:
- Update the
Propstype to reflect the generic
routes: Route<any, any>;
- Update the reactive pipeline’s line to remove the
mapToafter calling into
mergeMap(ctx => handleRoute(ctx, routes)),
This last change is because the
ctx object is now returned by
handleRoute() when it pipes the response through
handleResponse(). We can also remove the import of
mapTo from the top of the file.
Building routes with the new interface
With the new capabilities in place, a route in the application will be able to parse out any params in the route matcher, consume them in the route handler, and return an
Observable<Response> that emits the response when the route handler has completed its work.
Now that application-layer routes interface with the server through a defined API, the remaining concerns of building an application on this system are fairly straightforward. It should be clear how the asynchronous, Observable-based routes will work with typical application logic — calling external web APIs, performing database operations, and other such tasks.
My next addition to this routing framework would be more definition around serialization and deserialization: our route matchers could be composed with a system for type-aware route param and body validation, and similarly, response serialization could respect traits of returned data to provide conventions against unintentional leakage of internals through our web API.