An introduction to Deno

Deno is a JavaScript and TypeScript runtime build in Rust. It was created by Ryan Dahl, who is also the original creator of Node.js.

Eventually Deno might take over Node.js but at the moment, it's not there yet. Node.js is too popular and is already used in many projects. It has a much bigger community and much more modules/packages available.

Deno does not provide a strong enough incentive for companies to decide to make the switch. At least not yet.

Performance-wise it is comparable to Node.js, another reason not to switch.

So why am I writing an article about it? And what does Deno have that Node.js doesn't?

Well, I believe that if Ryan Dahl decided to build a new JS runtime, he must have had a good reason. He actually gave a talk about this.

In his talk he mentions multiple things he regrets about Node.js. Two big ones are that Node.js is insecure, by default it has access to everything (filesystem, network, etc) and NPM, the default package manager (bundled with Node.js) is centralized and private.

Deno by default does not have access to your network or your filesystem. It also allows you to use modules from anywhere without relying on a 3rd party tool, you just need to provide the full URL pointing to the module.

Another nice thing about Deno is that it runs TypeScript out of the box, you don't need a transpilation step nor a complicated configuration.

What are we going to build?

Now that I've introduced Deno, I will show you how to build a simple API with it. I will reproduce the example I made in my Rust guide.

We will create an API with the following endpoints

  • GET / This endpoint will simply return a text message Hello World!.

  • GET /items This endpoint will return the list of items we have in the database in a JSON format.

  • POST /items This endpoint will allow us to create a new item in the database. It expects a body of type:

type Body = {
name: string;
price: number;
};

The POST /items endpoint will be protected with an authorization middleware using JWT (JSON Web Token).

We will also setup velociraptor which allows us to define scripts in a file to run Deno commands more easily. It is a bit similar to the scripts property in Node.js's package.json.

Prerequisites

To be able to follow the guide, you will need to have Deno installed. You will need velociraptor as well, you can go ahead and install it by following the official documentation.

You will also need to have a MongoDB running. If you're not sure how to install MongoDB, there are 3 ways I can think of:

  • You can either install MongoDB on your machine. Follow the official documentation to do so.

  • If you have Docker, you can run a MongoDB container. Check out the mongo image documentation. This is the command I use to run the mongo container:

docker run -d -p 27017:27017 --name mongo mongo
  • If you prefer to avoid installing anything (MongoDB/Docker), you can create a free cluster on MongoDB Atlas.

Set up the environment

We need to add a couple environment variables in a .env file. If you haven't done it yet, create a new directory for our project and move inside it:

mkdir deno-example && cd deno-example

Then create the .env file

touch .env

Add the following two variables inside the file

MONGODB_URI=mongodb://127.0.0.1:27017
JWT_SECRET=my_secret

Adapt the MONGODB_URI if necessary. For the JWT_SECRET variable, change it to whatever you like.

It is good practice to have a .env.example as well, to document the environment variables. Go ahead and create that file

touch .env.example

Add the following in it

MONGODB_URI= ** MongoDB connection string (e.g. mongodb://127.0.0.1:27017)
JWT_SECRET= ** Secret to encode/decode JWTs

If you're using Git, make sure to add .env inside your .gitignore.

Add the dependencies

In Deno you can import the modules directly in your code by using their URL. To make things a bit cleaner and avoid having to copy and paste modules URLs all the time, we can instead create a import-map.json file where we define our dependencies and give them an alias.

Create the import-map.json file at the project root

touch import-map.json

Add the following

{
"imports": {
"oak": "https://deno.land/x/oak@v7.7.0/mod.ts",
"denodb": "https://deno.land/x/denodb@v1.0.38/mod.ts",
"dotenv": "https://deno.land/x/dotenv@v2.0.0/mod.ts",
"djwt": "https://deno.land/x/djwt@v2.2/mod.ts"
}
}

We can now import these modules using their alias: oak, denodb, dotenv and djwt.

Let's review the dependencies:

  • oak is our web library / framework, it is heavily inspired of koa the "successor" of express in Node.js.
  • denodb is an ORM supporting multiple databases including MongoDB.
  • dotenv is similar to the module of the same name in Node.js. It allows to inject environment variables from a file (by default .env).
  • djwt is a JWT encoding/parsing library.

Define the scripts

Now we will define two scripts to launch the API more easily.

Create a scripts.json file at the root of the project

touch scripts.json

And add the following

{
"imap": "import-map.json",
"scripts": {
"dev": {
"cmd": "src/main.ts",
"watch": true,
"allow": ["net", "read", "env"]
},
"start": {
"cmd": "src/main.ts",
"watch": false,
"allow": ["net", "read", "env"]
}
}
}

I've configured a global imap (short for import map) that will be used by all the scripts. Then I've defined two scripts, dev and start. They both do the same thing except that dev will watch for file changes.

The API will need access to the network to communicate with MongoDB and receive requests from the outside. It will also need read access to the filesystem to read the .env file. And finally it needs access to the environment to be able to add the variables defined in the .env file.

Let's code

First, create the src directory

mkdir src

Then create a main.ts file inside that directory

touch src/main.ts

From now on all the code that I will show should go inside that src/main.ts file. Of course in a real project you should split the code and logic in different subdirectories.

Let's start by importing the modules. Add the following at the top of the file

import { Application, Router, RouterMiddleware } from "oak";
import { Database, DataTypes, Model, MongoDBConnector } from "denodb";
import * as dotenv from "dotenv";
import * as jwt from "djwt";

Nothing complicated so far.

Now we will use dotenv to inject the environment variables from the .env file.

// Import environment variables from the ".env" file
// and ensure all the variables are defined
dotenv.config({ safe: true, export: true });

Using the safe option ensure that our .env matches variables defined in .env.example. The export option is to inject the variable inside Deno's environment (Deno.env).

To ensure the environment variables are set up correctly I will double check that Deno.env contains the variables defined in the .env file like so

const missingEnvVars = [];

// Double check that the the "MONGODB_URI" is defined
if (typeof Deno.env.get("MONGODB_URI") !== "string") {
missingEnvVars.push("MONGODB_URI");
}

// Double check that the the "JWT_SECRET" is defined
if (typeof Deno.env.get("JWT_SECRET") !== "string") {
missingEnvVars.push("JWT_SECRET");
}

// If any environment variable is missing throw an error
if (missingEnvVars.length > 0) {
throw new Error(
`The following environment variables are missing: ${missingEnvVars.join(
", "
)}
`

);
}

If any environment variable is missing I throw an error and the process stops.

Now I will set up the database. First I need to define the Item model

// Define the Item model from MongoDB
class Item extends Model {
static table = "items";
static timestamps = true;
static fields = {
_id: {
primaryKey: true,
},
name: DataTypes.STRING,
value: DataTypes.DECIMAL,
};
}

The Item class extends the Model class from the denodb module. The table static variable is in our case the name of the collection in MongoDB.

The timestamps static variable indicates whether or not to automatically add timestamps (createdAt and updatedAt).

The fields static variable defines the structure of an Item document. In MongoDB, we need a _id which is the primary key. Then we have the two fields, name of type string and price of type number.

Let's connect and initialize the database

// Create a MongoDB connection
const connector = new MongoDBConnector({
uri: Deno.env.get("MONGODB_URI") as string,
database: "deno-example",
});

// Instantiate the database
const db = new Database(connector);

// Define the database models
db.link([Item]);

// Setup the models in the database
db.sync();

Now, we will define our three controllers, the first one will simply return Hello World! as plain text

// Controller for the GET / route that simply returns "Hello World!"
const helloController: RouterMiddleware = (ctx) => {
ctx.response.body = "Hello World!";
};

The next one will retrieve all the items from the database and send them in JSON format

// Controller for the GET /items route to retrieve all the items from the database
const getItemsController: RouterMiddleware = async (ctx) => {
// Retrieve all the items from the database
const items: Item[] = await Item.all();

// Send the items in JSON format
ctx.response.body = items.map((item) => ({
id: item._id,
name: item.name,
price: item.price,
}));
};

The last one will allow to create new items in the database

// Controller for the POST /items route to create a new item in the database
const postItemController: RouterMiddleware = async (ctx) => {
// If there is no body return 400
if (ctx.request.hasBody === false) {
ctx.response.status = 400;
ctx.response.body = { error: "Missing body" };
return;
}

let body: { price: number; name: string };

try {
const b = ctx.request.body();

// If the body is no in JSON format return 400
if (b.type !== "json") {
ctx.response.status = 400;
ctx.response.body = { error: "Body should be in JSON format" };
return;
}

body = await b.value;
} catch (_err) {
// If there was an error parsing the body return 400
ctx.response.status = 400;
ctx.response.body = { error: "Could not parse the request's body" };
return;
}

// Extract "price" and "name" properties from the body
const { price, name } = body;

// If the "price" is not a number return 400
if (typeof price !== "number") {
ctx.response.status = 400;
ctx.response.body = {
error: 'Property "price" is incorrect or missing',
};
return;
}

// If the "name" is not a string return 400
if (typeof name !== "string") {
ctx.response.status = 400;
ctx.response.body = {
error: 'Property "name" is incorrect or missing',
};
return;
}

// Create a new item in the database with the values from the body
await Item.create({ name, price });

// Return 200
ctx.response.status = 200;
};

I've added comments in the controllers' code so that you understand what I'm doing, but it's pretty straightforward.

Unlike in my Rust tutorial, this time I made sure to validate the request's body in the postItemController.

We have one extra middleware to create, the authorization middleware. Here is the code

// Authorization middleware that checks that
// the "Authorization" header is present
// and that the token is valid
const authMiddleware: RouterMiddleware = async (ctx, next) => {
// Retrieve the request's headers
const headers = ctx.request.headers;
// Attempt to get the "Authorization" header if it exists
const authorizationHeader = headers.get("Authorization");

// If the "Authorization" header is missing
// or if it doesn't start with the prefix "Bearer " return 401
if (
authorizationHeader === null ||
authorizationHeader.startsWith("Bearer ") === false
) {
ctx.response.status = 401;
return;
}

// Extract the JWT from the header
const token = authorizationHeader.substr("Bearer ".length);

// Verify that the JWT is valid, if not return 401
try {
await jwt.verify(token, Deno.env.get("JWT_SECRET") as string, "HS256");
} catch (_err) {
ctx.response.status = 401;
return;
}

// Token is valid, proceed with the next middleware
await next();
};

Again, there are comments to help you understand. I'm checking that the Authorization header is present and starts with the prefix Bearer. Then I validate the token using the djwt module.

If anything goes wrong the middleware responds with the HTTP status code 401 for Unauthorized. Otherwise it calls the next function that simply runs the next middleware in the chain.

We just have to glue it all together now

// Create an "oak" app
const app = new Application();

// Create a router
const router = new Router();

router
.get("/", helloController)
.get("/items", getItemsController)
.post("/items", authMiddleware, postItemController);

// Add router's routes to the "oak" app
app.use(router.routes());
// Register middleware that automatically returns 405 or 501 when appropriate
app.use(router.allowedMethods());

// Start the server on port 8080
await app.listen({ port: 8080 });

I created the oak app and a new router. I defined the three routes. For the POST /items route I adde the authMiddleware to ensure that only users with the token can create items.

Finally I simply added the router's routes to the oak app and started the server.

Let's try out the API

We can now try out the API! Run it using

vr start

The API should be running on port 8080.

Send a GET request to /. You should receive a response with the HTTP status code 200 and a plain text message Hello World!.

Now let's try to create an item. Send a POST request to /posts with the following body

{
"name": "coffee",
"price": 2.5
}

You will also need to add an authorization header to the request with the Bearer prefix. You can generate the JWT on jwt.io using the secret you defined in the .env file. Make sure to change the payload to an empty JSON object {}.

You should receive a response with the HTTP status code 200 and no content.

If you try sending the POST request without the authorization header or with an invalid token you will receive a HTTP status code 401 instead.

Finally send a GET request to /items. You should receive a response with the HTTP status code 200 and a list with the item you just created like so

[
{
"name": "coffee",
"price": 2.5
}
]

In conclusion

That's it! I hope you enjoyed the article and that it was helpful :)

You can find the the full example of this walk-through on GitHub: https://github.com/ncribt/deno-example

Bonus: Set up Visual Studio Code for Deno

If you're using Visual Studio Code, I would advise you to install the following extensions

And add the following to your settings

{
"editor.defaultFormatter": "denoland.vscode-deno",
"deno.enable": true,
"deno.lint": true,
"deno.importMap": "./import-map.json",
"deno.suggest.imports.hosts": {
"https://deno.land": true
}
}