🙌 40% off everything - Black Friday!

Coupon BF40 at checkout for our biggest discount ever.

days
hours
mins
secs

Handling Route Params in GET Requests with Deno

Jun 4, 2020 10 mins read

Deno post
 icon

Want expert skills? Here's what you need to know.

Show Me View courses

In this blog post you’ll learn how to handle route params in Deno when making HTTP GET requests!

This article follows on from our previous post on Handling Http GET requests in Deno - be sure to read it before this one!

Route params allow us to create a single endpoint to handle dynamic data, done through the URL - something like /api/user/:id if you’ve used them before!

Let’s explore how we can create exactly this to handle any value to be used as a route param, it could be a username like chriswhited or even a unique ID such as d52d32ac0b67.

Using http std lib provide by the deno framework, we’ll build some utility functions to create a routing table to tell our server what routes we are looking for. We’ll also learn to use route params from the configured route by a standardized structure.

Here’s where we ended up in the previous article:

// ./src/server.ts
import { serve, ServerRequest } from 'https://deno.land/[email protected]/http/server.ts';
import { Status } from 'https://deno.land/[email protected]/http/http_status.ts';

import { getUsers } from './services/user.service.ts';
import { User } from './models/user.model.ts';

const handleGetUsersReq = async (req: ServerRequest): Promise<void> => {
  const users: User[] = await getUsers();
  return req.respond({ status: Status.OK, body: JSON.stringify(users) });
};

const handleNotFound = async (req: ServerRequest): Promise<void> => {
  return req.respond({ status: Status.NotFound, body: JSON.stringify({ message: 'Request Not Found' }) });
};

for await (const req: ServerRequest of s) {
  if (req.method === 'GET' && req.url === '/api/user') {
    handleGetUsersReq(req);
  } else {
    handleNotFound(req);
  }
}

Super simple, super lightweight, but not very dynamic.

What we need to do first is build out some utility functions that allow us to define a routing table (a mapping of routes we want to handle), as well as the ability to extract our route segments, like route params, out of the configured route.

Imagine our API endpoint to retrieve a single User by the id. A configured route to achieve this could look something like /api/user/:id, where the :id value is dynamic and determined by the consumer of the API and represents the id of the User we want to retrieve.

So let’s build our route utility functions and define our routing table.

First, define a type that represents a route in our routing table and contains the HTTP method it is listening on (GET, POST, PUT, etc), the route (/api/user, /api/user/:id), and a function that will be invoked to handle the route if it matches the received request:

export const enum HttpMethod {
  GET = 'GET',
  POST = 'POST',
  PUT = 'PUT',
  PATCH = 'PATCH',
  DELETE = 'DELETE',
}

export type Route = {
  method: HttpMethod;
  route: string;
  handler: (...params: any) => Promise<any>;
};

Now, we need to define a type that represents a parameter in our route. This will contain the index of the parameter in the configured route, as well as the key of the parameter. For instance, from our route above: /api/user/:id, grabbing the :id value, we split on the / to get our route segments (['api', 'user', ':id']), from this array, we know that the index of our :id param is 2 (since JavaScript arrays are zero-based) and that the key is id.

export type RouteParam = {
  idx: number;
  paramKey: string;
};

And now lets build a pure utility function that will read our route and return an array of RouteParam objects for any found route param:

/**
 * Extract all the route params out of the route into an array of RouteParam.
 * Example: /api/user/:id => [{ idx: 2, paramKey: 'id' }]
 * @param route the configured route with params
 */
export const extractRouteParams: (route: string) => RouteParam[] = (route) =>
  route.split('/').reduce((accum: RouteParam[], curr: string, idx: number) => {
    if (/:[A-Za-z1-9]{1,}/.test(curr)) {
      const paramKey: string = curr.replace(':', '');
      const param: RouteParam = { idx, paramKey };
      return [...accum, param];
    }
    return accum;
  }, []);

As I mentioned, we split the received route on the / and Array Reduce the resulting string array to an array of RouteParam objects, by checking if the route segment matches the given regex which will match both string and number params. If it does match we remove the : from the key and grab the index to create a RouteParam instance.

Now let’s put it all together:

// ./src/services/router.service.ts
export const enum HttpMethod {
  GET = 'GET',
  POST = 'POST',
  PUT = 'PUT',
  PATCH = 'PATCH',
  DELETE = 'DELETE',
}

export type Route = {
  method: HttpMethod;
  route: string;
  handler: (...params: any) => Promise<any>;
};

export type RouteParam = {
  idx: number;
  paramKey: string;
};

/**
 * Extract all the route params out of the route into an array of RouteParam.
 * Example: /api/user/:id => [{ idx: 2, paramKey: 'id' }]
 * @param route the configured route with params
 */
export const extractRouteParams: (route: string) => RouteParam[] = (route) =>
  route.split('/').reduce((accum: RouteParam[], curr: string, idx: number) => {
    if (/:[A-Za-z1-9]{1,}/.test(curr)) {
      const paramKey: string = curr.replace(':', '');
      const param: RouteParam = { idx, paramKey };
      return [...accum, param];
    }
    return accum;
  }, []);

export const routeParamPattern: (route: string) => string = (route) =>
  route.replace(/\/\:[^/]{1,}/gi, '/[^/]{1,}').replace(/\//g, '\\/');

This is super handy and we can use this functionality to build our routing table.

Let’s update our server to establish our routing table with some routes, then as a request comes in, use the request data to find the route in the routing table and invoke the handler function of the matched route.

Our routing table will be pretty basic to start, we will have a route to retrieve a list of users as well as retrieve a single user details using their passed in id, which in our case is a number.

// ./src/server.ts
const routes: Route[] = [
  {
    method: HttpMethod.GET,
    route: '/api/user',
    handler: getUsers,
  },
  {
    method: HttpMethod.GET,
    route: '/api/user/:id',
    handler: getUser,
  },
];

Now to update the route matching functionality to filter the requests by first checking the HTTP verb (GET, POST, PUT, etc), then it checks for a basic match where the routes match one-to-one (this will be used to catch our /api/user route):

// ./src/server.ts

/**
 * Basic route matcher. Check to see if the method and url match the route
 * @param req the received request
 * @param route the route being checked against
 */
export const basicRouteMatcher = (req: ServerRequest, route: Route): boolean =>
  req.method === route.method && req.url === route.route;

We will also have a route matcher that will match the route if the route has params in it (this will be used to catch our /api/user/:id route).

// ./src/server.ts

/**
 * Attempt to match the route if it has route params. For instance /api/user/12 to match with /api/user/:id.
 * @param req the received HTTP request
 * @param route the route being checked against
 */
export const routeWithParamsRouteMatcher = (req: ServerRequest, route: Route): boolean => {
  const routeMatcherRegEx = new RegExp(`^${routeParamPattern(route.route)}$`);
  return req.method === route.method && route.route.includes('/:') && routeMatcherRegEx.test(req.url);
};

This uses a regex to replace any configured route params with the received route to test the match; so for us, since our configured route is /api/user/:id and the route we receive is /api/user/1 this regex will be used to replace :id with the 1 received from the request and check if it matches.

Now we put these two matchers to use by checking our received request and attempting to first match on the basic matcher, then with the params matcher. If neither of these return a Route instance from our routing table, we will throw a 404 - NOT FOUND; otherwise, we will invoke the handler function on the Route and pass the response back in the request:

// ./src/server.ts

/**
 * Match the received request to a route in the routing table. return the handler function for that route
 * @param routes the routing table for the API
 * @param req the received request
 */
export const matchRequestToRouteHandler = async (routes: Route[], req: ServerRequest): Promise<void> => {
  let route: Route | undefined = routes.find((route: Route) => basicRouteMatcher(req, route));
  if (route) {
    const response: any = await route.handler();
    return req.respond({ status: Status.OK, body: JSON.stringify(response) });
  }
  route = routes.find((route: Route) => routeWithParamsRouteMatcher(req, route));
  if (route) {
    // the received route has route params, extract the route params from the route
    const routeParamsMap: RouteParam[] = extractRouteParams(route.route);
    const routeSegments: string[] = req.url.split('/');
    const routeParams: { [key: string]: string | number } = routeParamsMap.reduce(
      (accum: { [key: string]: string | number }, curr: RouteParam) => {
        return {
          ...accum,
          [curr.paramKey]: routeSegments[curr.idx],
        };
      },
      {}
    );
    const response: any = await route.handler(...Object.values(routeParams));
    return req.respond({ status: Status.OK, body: JSON.stringify(response) });
  }
  // route could not be found, return 404
  return req.respond({ status: Status.NotFound, body: 'Route not found' });
};

Notice that if our route matches from our routeWithParamsRouteMatcher then we need to pull the route params out of the received route and match them with our extracted RouteParam instances using the extractRouteParams.

With these combined, we can grab the param values from the received request and set it to the key from our extracted RouteParams giving us an object containing the key from the configured route and the value from the received request.

For example, if we received the request /api/user/12, we would get an object that looks like this:

{
  id: 12;
}

These values are then passed to our route handler function to invoke it and get our response and return.

Now we put it all together:

// ./src/server.ts
import { serve, ServerRequest } from 'https://deno.land/[email protected]/http/server.ts';
import { Status } from 'https://deno.land/[email protected]/http/http_status.ts';

import { getUsers, getUser } from './services/user.service.ts';
import { Route, HttpMethod, RouteParam, extractRouteParams, routeParamPattern } from './services/router.service.ts';

const s = serve({ port: 8000 });

console.log(`server running on port 8000`);

const routes: Route[] = [
  {
    method: HttpMethod.GET,
    route: '/api/user',
    handler: getUsers,
  },
  {
    method: HttpMethod.GET,
    route: '/api/user/:id',
    handler: getUser,
  },
];

/**
 * Basic route matcher. Check to see if the method and url match the route
 * @param req the received request
 * @param route the route being checked against
 */
export const basicRouteMatcher = (req: ServerRequest, route: Route): boolean =>
  req.method === route.method && req.url === route.route;

/**
 * Attempt to match the route if it has route params. For instance /api/user/12 to match with /api/user/:id.
 * @param req the received HTTP request
 * @param route the route being checked against
 */
export const routeWithParamsRouteMatcher = (req: ServerRequest, route: Route): boolean => {
  const routeMatcherRegEx = new RegExp(`^${routeParamPattern(route.route)}$`);
  return req.method === route.method && route.route.includes('/:') && routeMatcherRegEx.test(req.url);
};

/**
 * Match the received request to a route in the routing table. return the handler function for that route
 * @param routes the routing table for the API
 * @param req the received request
 */
export const matchRequestToRouteHandler = async (routes: Route[], req: ServerRequest): Promise<void> => {
  let route: Route | undefined = routes.find((route: Route) => basicRouteMatcher(req, route));
  if (route) {
    const response: any = await route.handler();
    return req.respond({ status: Status.OK, body: JSON.stringify(response) });
  }
  route = routes.find((route: Route) => routeWithParamsRouteMatcher(req, route));
  if (route) {
    // the received route has route params, extract the route params from the route
    const routeParamsMap: RouteParam[] = extractRouteParams(route.route);
    const routeSegments: string[] = req.url.split('/');
    const routeParams: { [key: string]: string | number } = routeParamsMap.reduce(
      (accum: { [key: string]: string | number }, curr: RouteParam) => {
        return {
          ...accum,
          [curr.paramKey]: routeSegments[curr.idx],
        };
      },
      {}
    );
    const response: any = await route.handler(...Object.values(routeParams));
    return req.respond({ status: Status.OK, body: JSON.stringify(response) });
  }
  // route could not be found, return 404
  return req.respond({ status: Status.NotFound, body: 'Route not found' });
};

for await (const req: ServerRequest of s) {
  matchRequestToRouteHandler(routes, req);
}

There we go! We can now fire up our API using the deno cli:

# run our server
deno run --allow-net ./src/server.ts # plug the location of your server file here
# server running on port 8000

Let’s curl it and get some data:

# get a list of users
curl http://localhost:8000/api/user # [{ id: 1, firstName: 'ultimate', lastName: 'courses', dob: '2020-01-01' }, { id: 2, firstName: 'test', lastName: 'test', dob: '2020-01-01' }]
# get user details
curl http://localhost:8000/api/user/1 # { id: 1, firstName: 'ultimate', lastName: 'courses', dob: '2020-01-01' }
# and a route that does not exist
curl http://localhost:8000/api/does-not-exist # Request Not Found

Now we have a much more robust routing system that we can add a lot more routes to and handle routes with 0 to many params!

🏆 Want to learn more JavaScript? Check out our JavaScript courses to fully learn the deep language basics, advanced patterns, functional and object-oriented programming paradigms and everything related to the DOM. A must-have series of courses for every serious JavaScript developer.

Thanks for reading, I hope you enjoyed the Deno article! Follow me on Twitter and let me know how you enjoyed the post.