DEV Community

Craig Morten
Craig Morten

Posted on • Updated on

Building a Simple API on Deno with Express

Deno is a new secure runtime for JavaScript and TypeScript created by Ryan Dahl, the original founder of Node.js.

Unlike Node, Deno does not use NPM or node modules for it's package management. In order to use third party modules you have to instead use browser compatible URLs which resolve to valid ES Modules - commonjs is not supported!

Deno also has a completely different underlying core and standard library to Node meaning that a lot of libraries are no longer compatible because everything from file-system operations to HTTP requests have a new API.

Fortunately, several module authors have already started converting Node modules over to support TypeScript and Deno. If you are a module maintainer for a Node module and are looking to support Deno, I recommend you check out the Denoify by @GarroneJoseph.

For this tutorial we are going to the Opine web framework - a fast, minimalist web framework for Deno ported from Express. It has almost exactly the same API as Express, and is a direct port to Deno, so the internal mechanics match Express exactly.

We will be building a simple Cat API for querying cat data! 🐱

Installing Deno

Deno can be installed using all the main package installers as well using the official installer scripts. Here are some of the main ways to install:

Shell (Mac, Linux):

curl -fsSL https://deno.land/x/install/install.sh | sh
Enter fullscreen mode Exit fullscreen mode

PowerShell (Windows):

iwr https://deno.land/x/install/install.ps1 -useb | iex
Enter fullscreen mode Exit fullscreen mode

Homebrew (Mac):

brew install deno
Enter fullscreen mode Exit fullscreen mode

Chocolatey (Windows):

choco install deno
Enter fullscreen mode Exit fullscreen mode

Head over to the Deno installation page for other installation methods and further details.

Getting started

Having installed Deno you can now run make use of the deno command. Use deno help to explore the commands on offer. We'll be using this command to run our API server later on.

Let's go and create our project! In a new directory create the following files:

.
β”œβ”€β”€ deps.ts
β”œβ”€β”€ db.ts
└── server.ts
Enter fullscreen mode Exit fullscreen mode

server.ts will contain our main server code, db.ts will hold our mock database code and deps.ts will hold all of our dependencies and versions (a bit like a substitute package.json).

Importing our dependencies

In the deps.ts file we add the following to re-export our required dependencies at the versions we need:

export { opine } from "https://deno.land/x/opine@0.21.2/mod.ts";
Enter fullscreen mode Exit fullscreen mode

Notice that we're importing it from a url? That's right, in Deno you can import modules from any URL and relative or absolute file path that exports a valid ES Module.

This means you can easily pull in any code from the web, e.g. gists, GitHub code and are no longer tied to versions that have been released - if there's something on a main branch (or any other feature branch!) that you can't wait to try, you can just import it!

We could choose to not use a deps.ts, and import these dependencies directly into our server code, but using a deps.ts file is useful for when you want to upgrade a dependency as you can change the version in one place rather than in all your files (especially in large projects)!

Writing our mock database

In db.ts we create a simple array of objects to represent our dummy database:

export const database = [
  {
    id: "abys",
    name: "Abyssinian",
    url: "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx",
    description:
      "The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.",
  },
  {
    id: "aege",
    name: "Aegean",
    url: "http://www.vetstreet.com/cats/aegean-cat",
    description:
      "Native to the Greek islands known as the Cyclades in the Aegean Sea, these are natural cats, meaning they developed without humans getting involved in their breeding. As a breed, Aegean Cats are rare, although they are numerous on their home islands. They are generally friendly toward people and can be excellent cats for families with children.",
  },
  {
    id: "abob",
    name: "American Bobtail",
    url: "http://cfa.org/Breeds/BreedsAB/AmericanBobtail.aspx",
    description:
      "American Bobtails are loving and incredibly intelligent cats possessing a distinctive wild appearance. They are extremely interactive cats that bond with their human family with great devotion.",
  },
  {
    id: "acur",
    name: "American Curl",
    url: "http://cfa.org/Breeds/BreedsAB/AmericanCurl.aspx",
    description:
      "Distinguished by truly unique ears that curl back in a graceful arc, offering an alert, perky, happily surprised expression, they cause people to break out into a big smile when viewing their first Curl. Curls are very people-oriented, faithful, affectionate soulmates, adjusting remarkably fast to other pets, children, and new situations.",
  },
  {
    id: "asho",
    name: "American Shorthair",
    url: "http://cfa.org/Breeds/BreedsAB/AmericanShorthair.aspx",
    description:
      "The American Shorthair is known for its longevity, robust health, good looks, sweet personality, and amiability with children, dogs, and other pets.",
  },
  {
    id: "awir",
    name: "American Wirehair",
    url: "http://cfa.org/Breeds/BreedsAB/AmericanWirehair.aspx",
    description:
      "The American Wirehair tends to be a calm and tolerant cat who takes life as it comes. His favorite hobby is bird-watching from a sunny windowsill, and his hunting ability will stand you in good stead if insects enter the house.",
  },
];

Enter fullscreen mode Exit fullscreen mode

This data has been taken from TheCatAPI - Cats as a Service, Everyday is Caturday, a free to use public API.

Server setup and our first endpoint

Now let's get started on writing our server!

import { opine, Router } from "./deps.ts";
import { database } from "./db.ts";

const app = opine();
const v1ApiRouter = Router();

// Add our /cats route to the v1 API router
// for retrieving a list of all the cats.
v1ApiRouter.get("/cats", (req, res) => {
  res.setStatus(200).json({
    success: "true",
    data: database,
  });
});

// Mount the v1 API router onto our server
// at the /api/v1 path.
app.use("/api/v1", v1ApiRouter);

const PORT = 3000;

// Start our server on the desired port.
app.listen(PORT);

console.log(`API server running on port ${PORT}`);
Enter fullscreen mode Exit fullscreen mode

First we import opine and Router from the Opine module in our deps.ts and we create a new Opine app and a v1ApiRouter router which is going to be used to define endpoints on the v1 of our API.

We then use the get() method on the v1ApiRouter to define a route to handle GET requests to endpoints matching the /cats path using the first parameter. The second parameter is a function that runs every time we hit that endpoint. This function takes two parameters which are req and res (though you can name these arguments however you like). The req object contains information about our request and the res object contains properties and methods for manipulating what information we send back to the user that requested the endpoint.

res.setStatus(200).json({
  success: "true",
  data: database,
});
Enter fullscreen mode Exit fullscreen mode

Using the res.setStatus() method we set the HTTP status code to 200 (OK) to let the user know that the request was successful. We then use the res.json() method, chained off of the res.setStatus() method, to send a JSON object back to the user as the response, containing our cat database information. You don't have to chain these methods and could equally write something like:

res.setStatus(200)
res.json({
  success: "true",
  data: database,
});
Enter fullscreen mode Exit fullscreen mode

We then add our v1 API router into our Opine app on the /api/v1 path by using the app.use() method:

app.use("/api/v1", v1ApiRouter);
Enter fullscreen mode Exit fullscreen mode

This command will now route any requests whose URL starts with /api/v1 to our v1 API Router.

Finally, we define a PORT as a constant and execute the app.listen() command to start the server.

If you are familiar with Express you will notice that these commands are almost exactly the same as what you would use when writing an Express application for Node. For comparison, here's the same code in Node:

const express = require("express");
const { database } = require("./database");

const app = express();
const v1ApiRouter = express.Router();

v1ApiRouter.get("/cats", (req, res) => {
  res.setStatus(200).json({
    success: "true",
    data: database,
  });
});

app.use("/api/v1", v1ApiRouter);

const PORT = 3000;

app.listen(PORT, () => console.log(`API server running on port ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

And that's it! Let's run our API server and see what happens πŸ˜„.

Run the following to start the server and then head to http://localhost:3000/api/v1/cats to see what it responds with:

deno run --allow-net ./server.ts
Enter fullscreen mode Exit fullscreen mode

You should see something in your browser like the response below.

Prettified cat API JSON response object in a browser

You successfully just written your first API in Deno! πŸŽ‰

Upload a cat API endpoint

We now have a way to get our cat details from our API, but we have no way to upload more cats 🐱. Let's write an upload endpoint now!

Make the following changes to your server.ts:

import { opine, Router } from "./deps.ts";
// *** NEW ***
import { getDatabase, addToDatabase } from "./db.ts"; 

const app = opine();
const v1ApiRouter = Router();

// Add our /cats route to the v1 API router
// for retrieving a list of all the cats.
v1ApiRouter.get("/cats", (req, res) => {
  res.setStatus(200).json({
    success: "true",
    data: getDatabase(), // *** NEW ***
  });
});

// *** NEW ***
// Add our /cats route to the v1 API router
// for uploading a cat to the database.
v1ApiRouter.put("/cats", (req, res) => {
  const cat = req.parsedBody;
  addToDatabase(cat);
  res.sendStatus(201);
});

// *** NEW ***
// We use the Opine JSON body parser to allow
// us to parse the upload cat JSON object.
app.use(json());

// ... the remaining code from our previous example
Enter fullscreen mode Exit fullscreen mode

There's a few changes here:

  1. We now import getDatabase and addToDatabase methods from db.ts for getting the database data, and for adding to the database. We will see how we write these methods a bit later.
  2. For the GET /cats request handler, we update data property in the JSON response to come from the method getDatabase().
  3. We have then added a new route handler onto the v1ApiRouter to handle PUT requests to the /cats endpoint. The function parameter takes the special property req.parsedBody which will contain our JSON cat object that we will be uploading and stores it in the variable cat. This new cat object is then added to the database using the addToDatabase() method. Finally the function calls the res.sendStatus() method with HTTP status code 201 (Created) to let the user know that they have successfully added the new cat to the database.
  4. The last change is the addition of app.use(json()). This is adding a special function from the Opine module which returns a middleware that will take the req.body of every request, and if it is a JSON request, will parse the JSON and store it on the req.parsedBody property. This is what allows use to access the cat data in our new PUT endpoint.

Now we just need to add the new database methods to our db.ts:

// ... the previous code from our example

export const getDatabase = () => database;

export const addToDatabase = (
  cat: { id: string; name: string; url: string; description: string },
) => database.push(cat);
Enter fullscreen mode Exit fullscreen mode

Here we export a getDatabase() method that just returns the database array, and also export a addToDatabase() method that accepts a cat object and performs a database.push(cat) to add the cat object to the database array.

Let's run the server again and see if we can upload a cat!

deno run --allow-net ./server.ts
Enter fullscreen mode Exit fullscreen mode

Below is a code snippet for using the terminal command curl to make a PUT request to the new /api/v1/cats endpoint, but you can also use any request making platform such as Postman.

curl -X PUT http://localhost:3000/api/v1/cats \
  -d '{ "id": "top-cat", "name": "Top Cat", "url": "https://en.wikipedia.org/wiki/Top_Cat", "description": "Top Cat (or simply T.C.) is the yellow-furred, charismatic, and clever cat." }' \
  -H 'Content-Type: application/json'
Enter fullscreen mode Exit fullscreen mode

Here we are making a PUT request to our endpoint and passing a JSON object as the data containing information about our cat Top Cat. We are also careful to add the Content-Type header to the request so that the Opine server knows that the request body is JSON.

Execute the command and then open http://localhost:3000/api/v1/cats in the browser again...

Updated cat API JSON response object containing the Top Cat object in a browser

Voila! πŸŽ‰ πŸŽ‰ We can see that our new cat has been added to the database πŸ˜„

Next steps

We've just seen how to implement a GET and PUT endpoint for retrieving and uploading cats to a database in an Opine server using Deno. We've seen how we can mount a router onto an applications, use multiple route handlers and also how to add a JSON parsing middleware for processing JSON request bodies.

If you want to take this further, why not try implementing one of the following:

  • Add a GET /cat/:id endpoint for getting just a single cat by using it's id. You can use this Opine example for some guidance on how you might implement a wildcarded route, or you can check out the Opine Router Docs for more help.
  • Add a DELETE /cat/:id endpoint for deleting a cat by id. This should be very similar to the one above, the tricky bit will be making sure you delete the correct cat from the database!
  • Add some validation to your endpoints. What if the user tries to get or delete a cat that doesn't exist? What if the user tries to upload a cat that is missing properties? Why not add some if statements to make sure that the request is valid, and if not, return a 400 (Bad Request) or 404 (Not Found).

That's all gang! Would love to hear your thoughts and how you're getting on with Deno - drop your comments and questions below! Will happily try to help πŸ˜„

Top comments (5)

Collapse
 
davidquartz profile image
David Quartey • Edited

Great article. Unfortunately, this does not work any more. We should use the latest version of opine and include, json and Router in the export from './deps.ts'.

Also, opine is now in maintenance mode: Deno has introduced Node and NPM compat, considering using Express itself in Deno! -copied from deno.land/x/opine@2.3.3

Collapse
 
garronej profile image
Garrone Joseph

Great article!

And thanks for the mention πŸ‘

Collapse
 
dividedbynil profile image
Kane Ong • Edited

Nice project! Do you mind to share the reason behind the inconsistencies between Express.js and Opine (other than the nature of Deno and Node.js)?

Collapse
 
craigmorten profile image
Craig Morten • Edited

More than happy to - any ones in particular? I'm happy to expand on them πŸ˜„

I'd say atm it comes down mostly to time spent on the project, it's quite new and (first commit 9 days ago) and getting the volume of features / API that Express has ported across in a way that allows for early adoption in a short space of time has meant that some things have dropped of the priority. Differences atm fall into 2 buckets:

  1. The feature hasn't been implemented. For example I have yet to port the view engine logic and app.params() API over yet. These are more complicated, less used (could be wrong) features that aren't essential for a core framework - both could be implemented as custom middleware. I do however very much intend to deliver these - PRs / contributions also welcome!
  2. The feature has been implemented, but has a subtly different API / functionality. This is due to 2 reasons:
    1. Time again, there is nothing stopping the feature / API fully mirroring Express.
    2. Deno has a different API. With this one there are a few places, such as cookies, where the std Deno library ships cookie methods, but they don't support the functionality levels of Express - with these we can either wait till they do, write our own implementation in Opine, or contribute the feature gaps to Deno itself. In other places it becomes a little difficult. A prime example is the req.body property in Express on Node vs Opine on Deno. In Node the IncomingMessage class supports a body parameter that can be overwritten allowing for body-parsers to drop the parsed body in place. With Deno, the body attribute of the ServerRequest class is actually a getter with no set method so natively there is no way to overwrite it. It does make use of a private req._body which we could use, but that would be breaking all sorts of programming rules. With this particular issue I've paused for now but am fairly certain, if it is highly desired, there is a suitable workaround using a Proxy or more specifically a Revokable Proxy which could act as an interface to the user during middleware execution, and then be revoked to allow the private internals of Deno's std http library to do it's thing undisturbed upon sending of the headers + body. This could be flagged similarly to how the res.headersSent boolean is implemented in Express.

Let me know if there was anything else you wanted to know!

Collapse
 
dividedbynil profile image
Kane Ong

Thanks for the very detailed explanation! That's a wise decision to make the naming inconsistent from the abovementioned points.