Building Microservices with Deno, Reno, and PostgreSQL
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 memoryPOST
: 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 databaseGET /posts/<UUID>
: retrieves the metadata and content of the post with the given UUIDPOST /posts
: adds a new post to the databasePATCH /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: