Skip to main content

Building Microservices with Deno, Reno, and PostgreSQL

By James Wright

JavaScript

Share:

Free JavaScript Book!

Write powerful, clean and maintainable JavaScript.

RRP $11.95

In this tutorial, we show you how to go about building microservices with Deno, and introduce you to Reno — a thin routing library for Deno. We’ll explore how we can use this newer JavaScript platform to build a microservice that exposes endpoints for acting on a database.

Deno is a JavaScript and TypeScript runtime from Node.js creator Ryan Dahl that aims to address some of the latter technology’s shortcomings, such as simplifying the module path lookup algorithm and more closely aligning the core APIs with their browser-based equivalents. Despite these fundamental differences, the potential applications of Deno and Node.js are mostly identical. One of Node’s core strengths lies in building HTTP services, and the same can be argued for Deno.

Writing HTTP Servers with std/http

Before we introduce a routing library or contemplate our data access layer, it would be useful to step back and build a simple HTTP server with the std/http module, which is part of Deno’s standard library. If you haven’t already, install Deno. In a Unix-type operating system, you can run:

$ curl -fsSL https://deno.land/x/install/install.sh | sh -s v1.3.0

Note that this tutorial has been developed against 1.3.0 (and std 0.65.0 as we’ll see later), but any later 1.x versions you may be using should be compatible. Alternatively, if you’re running an older version of Deno, you can upgrade to 1.3.0 with the deno upgrade command:

deno upgrade --version 1.3.0

You can verify that the expected Deno version has been installed with deno --version.

We’re now in a position to build an HTTP server. Create a directory, within your usual development directory, named deno-hello-http, and open it in your editor. Then, create a file called server.ts, and use the listenAndServe function within std/http to build our server:

import { listenAndServe } from "https://deno.land/std@0.65.0/http/mod.ts";

const BINDING = ":8000";

console.log(`Listening on ${BINDING}...`);

await listenAndServe(BINDING, (req) => {
  req.respond({ body: "Hello world!" });
});

Developer Experience Protips

If you’re using VS Code, I’d heavily recommend the official Deno extension, which provides support for Deno’s path resolution algorithm. Additionally, you can run deno cache server.ts to install the dependencies and their TypeScript definitions, the latter serving as an invaluable API guide when writing your code.

We can start our server by running deno run --allow-net server.ts in our shell. Note the --allow-net permissions flag, granting our program with network access. Once listening on port 8000, we can target it with a HTTP request:

$ curl -v http://localhost:8000/ ; echo

> GET / HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.58.0
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 12
<

Hello world!

Great! With a few lines of TypeScript, we’ve been able to implement a simple server. That said, it isn’t particularly well-featured at this point. Given that we consistently serve "Hello world!" from our callback function, the same response will be returned for any endpoint or HTTP method. If we hit a server with POST /add, we’ll receive the same headers and body:

$ curl -v -d '{}' http://localhost:8000/add ; echo

> POST /add HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Length: 2
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 200 OK
< content-length: 12
<

Hello world!

We can limit the existing response to GET / by conditionally checking the url and method properties of our callback’s req parameter:

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.65.0/http/mod.ts";

const BINDING = ":8000";

console.log(`Listening on ${BINDING}...`);

function notFound({ method, url }: ServerRequest) {
  return {
    status: 404,
    body: `No route found for ${method} ${url}`,
  };
}

await listenAndServe(BINDING, (req) => {
  const res = req.method === "GET" && req.url === "/"
    ? { body: "Hello world" }
    : notFound(req);

  req.respond(res);
});

If we restart our server, we should observe that GET / works as expected, but any other URL or method will result in a HTTP 404:

$ curl -v -d '{}' http://localhost:8000/add ; echo

> POST /add HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Length: 2
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 404 Not Found
< content-length: 28
<

No route found for POST /add

std/http Beyond Simple Services

Bootstrapping trivial HTTP servers with Deno and std/http has proven to be relatively straightforward. How does this approach scale for more complex services?

Let’s consider a /messages endpoint that accepts and returns user-submitted messages. Following a RESTful approach, we can define the behavior of this endpoint and of our service overall:

  • /messages
  • GET: returns a JSON-serialized array of all messages stored in the server’s memory
  • POST: adds a new message to the in-memory array
  • All other methods will return HTTP 405 (Method Not Allowed)
  • All other URLs will return HTTP 404 (Not Found)

Let’s update our existing server.ts module so that it conforms to our new service specification:

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.65.0/http/mod.ts";

interface MessagePayload {
  message: string;
}

const BINDING = ":8000";

const decoder = new TextDecoder();
const messages: string[] = [];

function jsonResponse<TBody>(body: TBody, status = 200) {
  return {
    status,
    headers: new Headers({
      "Content-Type": "application/json",
    }),
    body: JSON.stringify(body),
  };
}

function textResponse(body: string, status = 200) {
  return {
    status,
    headers: new Headers({
      "Content-Type": "text/plain",
    }),
    body,
  };
}

async function addMessage({ body }: ServerRequest) {
  const { message }: MessagePayload = JSON.parse(
    decoder.decode(await Deno.readAll(body)),
  );

  messages.push(message);

  return jsonResponse({ success: true }, 201);
}

function getMessages() {
  return jsonResponse(messages);
}

function methodNotAllowed({ method, url }: ServerRequest) {
  return textResponse(
    `${method} method not allowed for resource ${url}`,
    405,
  );
}

function notFound({ url }: ServerRequest) {
  return textResponse(`No resource found for ${url}`, 404);
}

function internalServerError({ message }: Error) {
  return textResponse(message, 500);
}

console.log(`Listening on ${BINDING}...`);

await listenAndServe(BINDING, async (req) => {
  let res = notFound(req);

  try {
    if (req.url === "/messages") {
      switch (req.method) {
        case "POST":
          res = await addMessage(req);
          break;
        case "GET":
          res = getMessages();
          break;
        default:
          res = methodNotAllowed(req);
      }
    }
  } catch (e) {
    res = internalServerError(e);
  }

  req.respond(res);
});

Restart the server and verify that GET /messages returns an application/json response with an empty JSON array as its body. We can then test that adding a message works by making a POST request to /messages with a valid payload and subsequently retrieving the messages:

$ curl -v -H "Content-Type: application/json" -d '{ "message": "Hello!" }' http://localhost:8000/messages ; echo
< HTTP/1.1 201 Created
< content-length: 16
< content-type: application/json
<

{"success":true}

$ curl -v http://localhost:8000/messages ; echo
< HTTP/1.1 200 OK
< content-length: 10
< content-type: application/json
<

["Hello!"]

Declaring Routes with Reno

Given that our service only provides a single endpoint, the code remains fairly unobtrusive. However, if it were to span many endpoints, then our route handling code would soon grow unmanageable:

if (req.url === "/messages") {
  switch (req.method) {
    case "POST":
      res = await addMessage(req);
      break;
    case "GET":
      // Route params e.g. /messages/ade25ef
      const [, id] = req.url.match(/^\/messages\/([a-z0-9]*)$/) || [];
      res = id ? getMessage(id) : getMessages();
      break;
    default:
      res = methodNotAllowed(req);
  }
} else if (req.url === "/topics") {
  switch (req.method) {
    case "GET":
      res = getTopics();
      break;
    default:
      res = methodNotAllowed(req);
  }
} else if (req.url === "/users") {
  // ...etc
}

We could certainly structure this code to make it more declarative, such as defining a Map of route handler functions that match a particular path, but we’d nonetheless have to handle the routing implementation ourselves, extending to route lookup, the parsing of path and query parameters, and nested routes. Even with the most nicely structured code, this is quite the task, and in a business context would eat into precious development time.

Over the last year, I’ve been working on Reno, a routing library for std/http‘s server that handles and abstracts much of this complexity, allowing us to focus on the core logic of our applications. Using its provided router accompanying functions, let’s rebuild our messages service:

import {
  listenAndServe,
  ServerRequest,
} from "https://deno.land/std@0.65.0/http/mod.ts";

import {
  createRouter,
  createRouteMap,
  forMethod,
  withJsonBody,
  jsonResponse,
  textResponse,
  ProcessedRequest,
  NotFoundError,
} from "https://deno.land/x/reno@v1.3.0/reno/mod.ts";

interface MessagePayload {
  message: string;
}

const BINDING = ":8000";

const messages: string[] = [];

async function addMessage(
  { body: { message } }: ProcessedRequest<MessagePayload>,
) {
  messages.push(message);
  return jsonResponse({ success: true }, {}, 201);
}

function getMessages() {
  return jsonResponse(messages);
}

function notFound({ url }: ServerRequest) {
  return textResponse(`No resource found for ${url}`, {}, 404);
}

function internalServerError({ message }: Error) {
  return textResponse(message, {}, 500);
}

const routes = createRouteMap([
  [
    "/messages",
    forMethod([
      ["GET", getMessages],
      ["POST", withJsonBody<MessagePayload>(addMessage)],
    ]),
  ],
]);

const router = createRouter(routes);

console.log(`Listening on ${BINDING}...`);

await listenAndServe(BINDING, async (req) => {
  try {
    req.respond(await router(req));
  } catch (e) {
    req.respond(
      e instanceof NotFoundError ? notFound(req) : internalServerError(e),
    );
  }
});

If you restart the server and make the same GET and POST requests to /messages, we’ll notice that the core functionality remains intact. To reiterate the complexity that Reno handles, here’s how the multiple endpoint example would look:

const routes = createRouteMap([
  [
    /^\/messages\/([a-z0-9]*)$/,
    forMethod([
      ["GET", ({ routeParams: [id] }) => id ? getMessage(id) : getMessages],
      ["POST", withJsonBody<MessagePayload>(addMessage)],
    ]),
  ],
  ["/topics", getTopics],
  ["/users", getUsers],
]);

Since Reno provides in-built path parsing and HTTP method handling out of the box, amongst its other features, we only need to concern ourselves with the declaration of our endpoints and the logic to respond to the requests they may receive.

One fundamental tenet of Reno worth highlighting is that it presents itself as a router-as-a-function. That is, const response = await router(request). As opposed to fully-fledged server frameworks that often take the onus of bootstrapping the HTTP server and managing its lifecycle, Reno is only concerned with the routing of requests, which it achieves with a standalone function call; this facilitates its adoption as well as its integration with existing Deno services.

Building Microservices with Reno

Given Reno’s small API, it’s well-suited to the development of microservices. In this instance, we’re going to build a blog post microservice with Deno and Reno, backed by a PostgreSQL database (we’ll be using the brilliant deno-postgres to query our database from Deno). Our service will expose a single /posts endpoint that supports a number of operations:

  • GET /posts: retrieves metadata for all of the posts in the database
  • GET /posts/<UUID>: retrieves the metadata and content of the post with the given UUID
  • POST /posts: adds a new post to the database
  • PATCH /posts/<UUID>: replaces the contents of the post with the given UUID

Building a fully-fledged microservice may sound like a daunting task for a single tutorial, but I’ve taken the courtesy of providing a substantial boilerplate that contains a Docker Compose setup and pre-written database scripts and queries. To get started, make sure that you’ve installed Docker and Docker Compose, and then [clone the Reno blog microservice, specifically checking out the sitepoint-boilerplate branch:

$ git clone --branch sitepoint-boilerplate https://github.com/reno-router/blog-microservice.git

Open the blog-microservice folder with your editor of choice. Before we implement our first route, I’ll discuss some of the key directories and files at a high level:

  • data: contains SQL scripts that will run when the database container is created, defining the tables of our application and populating them with some seed data.
  • service/blog_service.ts: provides methods for retrieving, creating, and updating posts stored in the database.
  • service/db_service.ts: a generic database abstraction that sits on top of deno-postgres, handling connection pooling and transactions for free.
  • service/queries.ts: predefined Postgres queries for our various database operations; the blog service passes these to the DB service and forwards the results in a consumable format to the caller. These queries are parameterised, the values of which deno-postgres will automatically santise.
  • service/server.ts: the entry point of our server.
  • deps.ts: a centralized module containing all external dependencies, allowing them to be maintained at a single point. This practice is common across Deno projects and is endorsed by the official manual.
  • Dockerfile: declares our production Docker container that will install our project’s dependencies at build time, drastically reducing the cold start time.
  • Dockerfile.local: declares our development Docker container, using Denon to automatically restart Deno whenever our source code changes.
  • docker-compose.yml: a Docker Compose configuration that includes both our development container and a Postgres container against which our SQL scripts are run, drastically reducing any prerequisite steps to running our project.

Let’s create our app’s routes. Within the service folder, create a new file named routes.ts. Populate it with these imports, which we’ll need shortly:

import {
  createRouteMap,
  jsonResponse,
  forMethod,
  DBPool,
  uuidv4,
} from "../deps.ts";

import createBlogService from "./blog_service.ts";
import createDbService from "./db_service.ts";

Next, let’s instantiate our database connection pool. Note that by using Object.fromEntries, we can build the options object required by deno-postgres in a relatively succinct fashion:

function createClientOpts() {
  return Object.fromEntries([
    ["hostname", "POSTGRES_HOST"],
    ["user", "POSTGRES_USER"],
    ["password", "POSTGRES_PASSWORD"],
    ["database", "POSTGRES_DB"],
  ].map(([key, envVar]) => [key, Deno.env.get(envVar)]));
}

function getPoolConnectionCount() {
  return Number.parseInt(Deno.env.get("POSTGRES_POOL_CONNECTIONS") || "1", 10);
}

const dbPool = new DBPool(createClientOpts(), getPoolConnectionCount());

With our instantiated connection pool, we can create our database and blog services:

const blogService = createBlogService(
  createDbService(dbPool),
  uuidv4.generate,
);

Now let’s write a route handler to retrieve all the posts in the database:

async function getPosts() {
  const res = await blogService.getPosts();
  return jsonResponse(res);
}

In order to bind our handler to GET /posts, we’ll need to declare a route map and export it:

const routes = createRouteMap([
  ["/posts", forMethod([
    ["GET", getPosts],
  ])],
]);

export default routes;

End-to-end, routes.ts should look like this:

import {
  createRouteMap,
  jsonResponse,
  forMethod,
  DBPool,
  uuidv4,
} from "../deps.ts";

import createBlogService from "./blog_service.ts";
import createDbService from "./db_service.ts";

function createClientOpts() {
  return Object.fromEntries([
    ["hostname", "POSTGRES_HOST"],
    ["user", "POSTGRES_USER"],
    ["password", "POSTGRES_PASSWORD"],
    ["database", "POSTGRES_DB"],
  ].map(([key, envVar]) => [key, Deno.env.get(envVar)]));
}

function getPoolConnectionCount() {
  return Number.parseInt(Deno.env.get("POSTGRES_POOL_CONNECTIONS") || "1", 10);
}

const dbPool = new DBPool(createClientOpts(), getPoolConnectionCount());

const blogService = createBlogService(
  createDbService(dbPool),
  uuidv4.generate,
);

async function getPosts() {
  const res = await blogService.getPosts();
  return jsonResponse(res);
}

const routes = createRouteMap([
  ["/posts", forMethod([
    ["GET", getPosts],
  ])],
]);

export default routes;

To forward requests to our handler, we’ll need to update the existing server.ts module. Add createRouter to the bindings imported from deps.ts:

import {
  listenAndServe,
  ServerRequest,
  textResponse,
  createRouter,
} from "../deps.ts";

Below this statement, we’ll need to import our routes:

import routes from "./routes.ts";

To create our service’s router, call the createRouter function above the server listening message, passing our routes as the only argument:

const router = createRouter(routes);

Finally, to forward incoming requests to our router and to return the intended response, let’s call the router within the try block of our server’s callback:

try {
  const res = await router(req);
  return req.respond(res);
}

We’re now in a position to run our app, but there’s one last step. We need to rename the .env.sample file to .env. It has the .sample suffix to denote that it doesn’t contain any real-world, sensitive values, but to get started we can nonetheless use them verbatim:

$ mv .env.sample .env

With a swift docker-compose up, we should see the database and service containers come to life, the latter ultimately listening on port 8000:

$ docker-compose up

# [...]

db_1   | 2020-08-16 22:04:50.314 UTC [1] LOG:  database system is ready to accept connections
# [...]
api_1  | Listening for requests on :8000...

Once bound to that port, we should verify that our endpoint works. It should return the ID, title, and tags for each post in the database, currently populated by the seed data:

# jq is like sed for JSON data:
# https://stedolan.github.io/jq/

$ curl http://localhost:8000/posts | jq
[
  {
    "id": "006a8213-8aac-47e2-b728-b0e2c07ddaf6",
    "title": "Go's generics experimentation tool",
    "author": {
      "id": "c9e69690-9246-41bf-b912-0c6190f64f1f",
      "name": "Joe Bloggs"
    },
    "tags": [
      {
        "id": "f9076c31-69eb-45cf-b51c-d7a1b6e3fe0c",
        "name": "Go"
      }
    ]
  },
  {
    "id": "16f9d2b0-baf9-4618-a230-d9b95ab75fa8",
    "title": "Deno 1.3.0 released",
    "author": {
      "id": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
      "name": "James Wright"
    },
    "tags": [
      {
        "id": "21c1ac3a-9c1b-4be1-be50-001b44cf84d1",
        "name": "JavaScript"
      },
      {
        "id": "ac9c2f73-6f11-470f-b8a7-9930dbbf137a",
        "name": "TypeScript"
      },
      {
        "id": "c35defc4-42f1-43b9-a181-a8f12b8457f1",
        "name": "Deno"
      },
      {
        "id": "d7c2f180-18d6-423e-aeda-31c4a3a7ced1",
        "name": "Rust"
      }
    ]
  }
]

Retrieving the Contents of a Post

The next operation to implement is GET /posts/<UUID>. Given we’re already handling GET /posts, we can make a minimal set of changes to retrieve individual posts by their ID. First of all, let’s tweak the "/posts" path binding in our routes map to introduce a wildcard path segment:

const routes = createRouteMap([
  ["/posts/*", forMethod([
    ["GET", getPosts],
  ])],
]);

In addition to regular expressions, Reno allows string paths to be used with wildcards (‘*’) that will be captured and exposed via the request’s routeParams property. Although they aren’t as specific as regular expressions, they’re arguably easier to read and are mostly a means to the same end. Let’s update the getPosts route handler to determine the existence of the path parameter and retrieve an individual post from the blog service if it’s present (the AugmentedRequest type can be imported from deps.ts):

async function getPosts({ routeParams: [id] }: AugmentedRequest) {
  const res = await (id ? blogService.getPost(id) : blogService.getPosts());
  return jsonResponse(res);
}

Note that routeParams is a linearly ordered array, with each item referring to the path parameter in the order they’re declared. In our case, we can thus ascertain that the first item always refers to a post ID. Upon saving our changes, Denon will detect the changes and restart Deno, and calling GET /posts followed by the ID of one of our posts should return its metadata and contents:

$ curl http://localhost:8000/posts/16f9d2b0-baf9-4618-a230-d9b95ab75fa8 | jq
{
  "id": "16f9d2b0-baf9-4618-a230-d9b95ab75fa8",
  "title": "Deno 1.3.0 released",
  "contents": "This release includes new flags to various Deno commands and implements the W3C FileReader API, amongst other enhancements and fixes.",
  "author": {
    "id": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
    "name": "James Wright"
  },
  "tags": [
    {
      "id": "21c1ac3a-9c1b-4be1-be50-001b44cf84d1",
      "name": "JavaScript"
    },
    {
      "id": "ac9c2f73-6f11-470f-b8a7-9930dbbf137a",
      "name": "TypeScript"
    },
    {
      "id": "c35defc4-42f1-43b9-a181-a8f12b8457f1",
      "name": "Deno"
    },
    {
      "id": "d7c2f180-18d6-423e-aeda-31c4a3a7ced1",
      "name": "Rust"
    }
  ]
}

Dealing with Non-existent Posts

Extending our GET /posts operation to retrieve an individual post by its ID has resulted in a bug. Let’s request the contents of a post for a non-existent ID:

$ curl -v http://localhost:8000/posts/b801087e-f1c9-4b1e-9e0c-70405b685e86

> GET /posts/b801087e-f1c9-4b1e-9e0c-70405b685e86 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 0
< content-type: application/json
<

Since blogService.getPost(id) returns undefined when a post with the given ID can’t be found, our current handler results with a HTTP 200 response with an empty body. It would be preferable to surface this error to the requester. To keep the getPosts function readable, let’s lift the blogService.getPost(id) call into its own function, in which we’ll throw an error if the retrieved post is undefined. The BlogService type can be imported from blog_service.ts:

async function getPost(blogService: BlogService, id: string) {
  const res = await blogService.getPost(id);

  if (!res) {
    throw new Error(`Post not found with ID ${id}`);
  }

  return res;
}

async function getPosts({ routeParams: [id] }: AugmentedRequest) {
  const res = await (id ? getPost(blogService, id) : blogService.getPosts());
  return jsonResponse(res);
}

If we now request a post that doesn’t exist, we’ll be provided with an error response:

$ curl -v http://localhost:8000/posts/b801087e-f1c9-4b1e-9e0c-70405b685e86 ; echo

> GET /posts/b801087e-f1c9-4b1e-9e0c-70405b685e86 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 500 Internal Server Error
< content-length: 59
< content-type: text/plain
<

Post not found with ID b801087e-f1c9-4b1e-9e0c-70405b685e86

This is certainly an improvement, but perhaps the status code isn’t accurate. This response isn’t the result of an application error, but of the user specifying a missing post. In this instance, a HTTP 404 would be a better fit. Above the getPost function, we can define a custom error class to throw when a post isn’t found:

export class PostNotFoundError extends Error {
  constructor(id: string) {
    super(`Post not found with ID ${id}`);
  }
}

Then, within the body of getPost, we can throw this instead of a vanilla Error instance:

async function getPost(blogService: BlogService, id: string) {
  const res = await blogService.getPost(id);

  if (!res) {
    throw new PostNotFoundError(`Post not found with ID ${id}`);
  }

  return res;
}

The benefit of throwing a custom error is that we’re able to serve a particular response when it’s caught. In server.ts, let’s update the switch statement in the mapToErrorResponse function to return a call to notFound() when our PostNotFoundError occurs:

function mapToErrorResponse(e: Error) {
  switch (e.constructor) {
    case PostNotFoundError:
      return notFound(e);
    default:
      return serverError(e);
  }
}

Upon retrying the previous request, we should now see that we receive an HTTP 404:

$ curl -v http://localhost:8000/posts/b801087e-f1c9-4b1e-9e0c-70405b685e86 ; echo

> GET /posts/b801087e-f1c9-4b1e-9e0c-70405b685e86 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< content-length: 82
< content-type: text/plain
<

Post not found with ID Post not found with ID b801087e-f1c9-4b1e-9e0c-70405b685e86

We should also add Reno’s NotFoundError to this case, which will also result in a HTTP 404 being served if a request route does not exist:

switch (e.constructor) {
  case PostNotFoundError:
  case NotFoundError:
    return notFound(e);
  default:
    return serverError(e);
}

We can follow this pattern to handle other kinds of error across our application. For example, the full service serves a HTTP 400 (Bad Request) when the user creates a resource with an invalid UUID.

Adding New Posts to the Database

So far, the operations we’ve implemented read posts from the database. What about creating new posts? We can add a route handler for this, but first we’ll need to import withJsonBody from deps.ts into routes.ts:

import {
  createRouteMap,
  jsonResponse,
  forMethod,
  DBPool,
  uuidv4,
  AugmentedRequest,
  withJsonBody,
} from "../deps.ts";

We should also import the CreatePostPayload interface from blog_service.ts, which we’ll require shortly:

import createBlogService, {
  BlogService,
  CreatePostPayload,
} from "./blog_service.ts";

withJsonBody is a higher-order route handler that will assume that the underlying request body is a JSON-serialized string and parse it for us. It also supports a generic parameter that allows us to assert the type of the body. Let’s use it to define our addPost handler:

const addPost = withJsonBody<CreatePostPayload>(
  async function addPost({ body }) {
    const id = await blogService.createPost(body);
    return jsonResponse({ id });
  },
);

We must then register the handler in our route map:

const routes = createRouteMap([
  [
    "/posts/*",
    forMethod([
      ["GET", getPosts],
      ["POST", addPost],
    ]),
  ],
]);

To test that our POST /posts operation is working, we can make this request with a valid post creation payload:

$ curl -H "Content-Type: application/json" -d '{
  "authorId": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
  "title": "New post",
  "contents": "This was submitted via our new API endpoint!",
  "tagIds": ["6a7e1f4d-7fca-4573-b138-f2cba0163077", "f9076c31-69eb-45cf-b51c-d7a1b6e3fe0c"]
}' http://localhost:8000/posts | jq
{
  "id": "586bb055-cea6-4d56-8d8d-1856e8f8e5eb"
}

We can then ensure that this has been successfully stored in our database by requesting the post by the generated UUID:

$ curl http://localhost:8000/posts/586bb055-cea6-4d56-8d8d-1856e8f8e5eb | jq
{
  "id": "586bb055-cea6-4d56-8d8d-1856e8f8e5eb",
  "title": "New post",
  "contents": "This was submitted via our new API endpoint!",
  "author": {
    "id": "91ef4450-97ff-44da-8b1d-f1560e9d10cc",
    "name": "James Wright"
  },
  "tags": [
    {
      "id": "6a7e1f4d-7fca-4573-b138-f2cba0163077",
      "name": "C#"
    },
    {
      "id": "f9076c31-69eb-45cf-b51c-d7a1b6e3fe0c",
      "name": "Go"
    }
  ]
}

Editing Existing Posts

To wrap up our service, we’re going to implement the PATCH /posts/<UUID> route, which enables the contents of a post to be replaced. Let’s begin by importing the EditPostPayload interface from blog_service.ts:

import createBlogService, {
  BlogService,
  CreatePostPayload,
  EditPostPayload,
} from "./blog_service.ts";

Next, we should add a route handling function called editPost:

const editPost = withJsonBody<EditPostPayload>(
  async function editPost({ body: { contents }, routeParams: [id] }) {
    const rowCount = await blogService.editPost(id, contents);

    if (rowCount === 0) {
      throw new PostNotFoundError(id);
    }

    return jsonResponse({ id });
  },
);

To conclude, let’s add the handler to our routes:

const routes = createRouteMap([
  [
    "/posts/*",
    forMethod([
      ["GET", getPosts],
      ["POST", addPost],
      ["PATCH", editPost],
    ]),
  ],
]);

We can establish that our handler works by updating the contents of the post we created in the previous section:

$ curl -X PATCH -H "Content-Type: application/json" -d '{
  "contents": "This was edited via our new API endpoint!"
}' http://localhost:8000/posts/586bb055-cea6-4d56-8d8d-1856e8f8e5eb | jq
{
  "id": "586bb055-cea6-4d56-8d8d-1856e8f8e5eb"
}
$ curl http://localhost:8000/posts/586bb055-cea6-4d56-8d8d-1856e8f8e5eb | jq .contents
"This was edited via our new API endpoint!"

Calling the GET /posts operation should also demonstrate that no additional posts have been stored in the database.

Next Steps

We’ve put together a well-designed and maintainable service, but there are still additional steps that would improve the robustness and security of our service, such as validating incoming payloads and authorizing the POST and PUT requests. Additionally, we could write some unit tests for our route handlers. Given that they’re effectively pure functions (that is, they produce a deterministic response for a given input, and side effects are optional), we can achieve this with relatively little overhead:

Deno.test(
  "getPosts route handler should retrieve the post for the given ID from the blog service",
  async () => {
    const id = "post ID";

    const post = {
      id,
      title: "Test Post",
      author: {
        id: "author ID",
        name: "James Wright",
      },
      tags: [
        { id: "tag ID", name: "JavaScript" },
        { id: "tag ID", name: "TypeScript" },
      ],
    };

    const blogService = {
      getPost: sinon.stub().resolves(post),
      getPosts: sinon.stub().resolves(),
    };

    const getPosts = createGetPostsHandler(blogService);
    const response = await getPosts({ routeParams: [id] });

    assertResponsesAreEqual(response, jsonResponse(post));
    assertStrictEquals(blogService.getPost.callCount, 1);
    assertStrictEquals(blogService.getPosts.callCount, 0);
  },
);

Note that we’re using partial application to inject the stub blog service into the route handler, which we can update accordingly:

export function createGetPostsHandler(
  blogService: Pick<BlogService, "getPosts" | "getPost">,
) {
  return async function getPosts(
    { routeParams: [id] }: Pick<AugmentedRequest, "routeParams">,
  ) {
    const res = await (id ? getPost(blogService, id) : blogService.getPosts());
    return jsonResponse(res);
  };
}

The actual service would then provide the real blog service to the handler in a similar way to the tests. Another interesting observation is that Pick<BlogService, "getPosts" | "getPost"> allows us to provide an implementation of BlogService with just a subset of its properties, meaning that we don’t have to define every single method to test handlers that don’t even need them.

Summary

Building small HTTP services with std/http is attainable, but managing additional endpoints, dedicated logic for particular HTTP methods, and error handling, can become burdensome as our applications grow. Reno conceals these complexities away from us, permitting us to focus on the core business logic of our microservices. Given the structure of route handler functions, applications that are routed with Reno intrinsically lend themselves to unit testing, and can easily integrate with existing Deno projects.

That said, larger or more complex services may benefit from a full framework such as Oak. For microservices, however, Reno provides a very small, unobtrusive API surface that allows them to scale as our business requirements grow.

Deno Foundations

Get up to speed with Deno. Our Deno Foundations collection helps you take your first steps into the Deno world and beyond, and we’re adding to it constantly. We’ll bring you the tutorials you need to become a pro. You can always refer to our index as it’s updated at the end of our Introduction to Deno:

Deno Foundations

James is a full-stack software developer who has a passion for web technologies. He is currently working with a variety of languages, and has engineered solutions for the likes of Sky, Channel 4, Trainline, and NET-A-PORTER.

New books out now!

Learn how Git works, and how to use it to streamline your workflow!


Google, Netflix and ILM are Python users. Maybe you should too?