DEV Community

Cover image for Write a small API using Deno
Kryz
Kryz

Posted on • Edited on

Write a small API using Deno

In this post, I will show you how to create a small API using Deno - the newest runtime to run Javascript and Typescript, created by the author of Node.js - Ryan Dahl.

If you don't know what Deno is, check this article: Getting started with Deno.

Our goal is to:

  • Create an API which manages users
  • Provide GET, POST, PUT and DELETE routes
  • Save created/updated users to a local JSON file
  • Use a web framework to speed up the development process

The only tool you need to install is Deno itself. Deno supports Typescript out of the box. For this example, I used the 0.22 version. The Deno API is still under a continuous development, and this code may not work with other versions. Check your version using: deno version command in the terminal.

Let's start

You can find the code below on Github: github.com/kryz81/deno-api-example

Step 1: Program structure

handlers
middlewares
models
services
config.ts
index.ts
routing.ts

As you see it looks like a small Node.js web application:

  • handlers contains route handlers
  • middlewares provide functions that run on every request
  • models contain model definitions, in our case only User interface
  • services contains... services
  • config.ts contains global application configuration
  • index.ts is the entry point of the application
  • routing.ts contains API routes

Step 2: Choose a web framework

There are many great web frameworks for Node.js. The most popular one is Express. There is also a modern version of Express - Koa. But Deno is not compatible with Node.js, and we cannot use Node.js libraries. In the case of Deno, the choice is currently much smaller, but there is a framework inspired by Koa - Oak. Let's use it for our example. If you've never used Koa, don't worry, it looks almost the same as Express.

Step 3: Create the main file

index.ts

import { Application } from "https://deno.land/x/oak/mod.ts";
import { APP_HOST, APP_PORT } from "./config.ts";
import router from "./routing.ts";
import notFound from "./handlers/notFound.ts";
import errorMiddleware from "./middlewares/error.ts";

const app = new Application();

app.use(errorMiddleware);
app.use(router.routes());
app.use(router.allowedMethods());
app.use(notFound);

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

await app.listen(`${APP_HOST}:${APP_PORT}`);

In the first line, we use the Deno feature - importing modules directly from the internet. Besides that, there is nothing special here. We create an application, add middleware, routes, and finally start the server. Just like in Express/Koa.

Step 4: Create a configuration

config.ts

const env = Deno.env();
export const APP_HOST = env.APP_HOST || "127.0.0.1";
export const APP_PORT = env.APP_PORT || 4000;
export const DB_PATH = env.DB_PATH || "./db/users.json";

Our configuration is flexible, settings are read from the environment, but we also provide default values used during development. Deno.env() is an equivalent of Node.js process.env.

Step 5: Add user model

models/user.ts

export interface User {
  id: string;
  name: string;
  role: string;
  jiraAdmin: boolean;
  added: Date;
}

We need this interface for proper typing.

Step 6: Add routes

routing.ts

import { Router } from "https://deno.land/x/oak/mod.ts";

import getUsers from "./handlers/getUsers.ts";
import getUserDetails from "./handlers/getUserDetails.ts";
import createUser from "./handlers/createUser.ts";
import updateUser from "./handlers/updateUser.ts";
import deleteUser from "./handlers/deleteUser.ts";

const router = new Router();

router
  .get("/users", getUsers)
  .get("/users/:id", getUserDetails)
  .post("/users", createUser)
  .put("/users/:id", updateUser)
  .delete("/users/:id", deleteUser);

export default router;

Again, nothing special, we create a router and add routes. It looks almost like a copy/paste from an Express.js application!

Step 7: Add route handlers

handlers/getUsers.ts

import { getUsers } from "../services/users.ts";

export default async ({ response }) => {
  response.body = await getUsers();
};

It returns all users. If you've never used Koa, the response object is like res in Express. The res object in Express has some methods like json or send, to return a response. In Koa/Oak, we need to attach our response value to the response.body property.

handlers/getUserDetails.ts

import { getUser } from "../services/users.ts";

export default async ({ params, response }) => {
  const userId = params.id;

  if (!userId) {
    response.status = 400;
    response.body = { msg: "Invalid user id" };
    return;
  }

  const foundUser = await getUser(userId);
  if (!foundUser) {
    response.status = 404;
    response.body = { msg: `User with ID ${userId} not found` };
    return;
  }

  response.body = foundUser;
};

It returns the user with the given id.

handlers/createUser.ts

import { createUser } from "../services/users.ts";

export default async ({ request, response }) => {
  if (!request.hasBody) {
    response.status = 400;
    response.body = { msg: "Invalid user data" };
    return;
  }

  const {
    value: { name, role, jiraAdmin }
  } = await request.body();

  if (!name || !role) {
    response.status = 422;
    response.body = { msg: "Incorrect user data. Name and role are required" };
    return;
  }

  const userId = await createUser({ name, role, jiraAdmin });

  response.body = { msg: "User created", userId };
};

This handler manages user creation.

handlers/updateUser.ts

import { updateUser } from "../services/users.ts";

export default async ({ params, request, response }) => {
  const userId = params.id;

  if (!userId) {
    response.status = 400;
    response.body = { msg: "Invalid user id" };
    return;
  }

  if (!request.hasBody) {
    response.status = 400;
    response.body = { msg: "Invalid user data" };
    return;
  }

  const {
    value: { name, role, jiraAdmin }
  } = await request.body();

  await updateUser(userId, { name, role, jiraAdmin });

  response.body = { msg: "User updated" };
};

The update handler checks if the user with the given ID exists and updates user data.

handlers/deleteUser.ts

import { deleteUser, getUser } from "../services/users.ts";

export default async ({ params, response }) => {
  const userId = params.id;

  if (!userId) {
    response.status = 400;
    response.body = { msg: "Invalid user id" };
    return;
  }

  const foundUser = await getUser(userId);
  if (!foundUser) {
    response.status = 404;
    response.body = { msg: `User with ID ${userId} not found` };
    return;
  }

  await deleteUser(userId);
  response.body = { msg: "User deleted" };
};

This handler deletes a user.

We would also like to handle non-exiting routes and return an error message:

handlers/notFound.ts

export default ({ response }) => {
  response.status = 404;
  response.body = { msg: "Not Found" };
};

Step 8: Add services

Before we create the user service, we need to create two small helper services.

services/createId.ts

import { v4 as uuid } from "https://deno.land/std/uuid/mod.ts";

export default () => uuid.generate();

Each new user gets a unique id, and for that, we will use uuid module from the Deno standard library.

services/db.ts

import { DB_PATH } from "../config.ts";
import { User } from "../models/user.ts";

export const fetchData = async (): Promise<User[]> => {
  const data = await Deno.readFile(DB_PATH);

  const decoder = new TextDecoder();
  const decodedData = decoder.decode(data);

  return JSON.parse(decodedData);
};

export const persistData = async (data): Promise<void> => {
  const encoder = new TextEncoder();
  await Deno.writeFile(DB_PATH, encoder.encode(JSON.stringify(data)));
};

This service helps us to interact with our fake users' storage, which is a local json file in our case. To fetch users, we read the file content. The readFile function returns an Uint8Array object, which needs to be converted to a string before parsing to JSON. Both Uint8Array and TextDecoder come from core Javascript API. Similarly, the data to persist needs to be converted from string to Uint8Array.

Finally, here is the main service responsible for managing user data:

services/users.ts

import { fetchData, persistData } from "./db.ts";
import { User } from "../models/user.ts";
import createId from "../services/createId.ts";

type UserData = Pick<User, "name" | "role" | "jiraAdmin">;

export const getUsers = async (): Promise<User[]> => {
  const users = await fetchData();

  // sort by name
  return users.sort((a, b) => a.name.localeCompare(b.name));
};

export const getUser = async (userId: string): Promise<User | undefined> => {
  const users = await fetchData();

  return users.find(({ id }) => id === userId);
};

export const createUser = async (userData: UserData): Promise<string> => {
  const users = await fetchData();

  const newUser: User = {
    id: createId(),
    name: String(userData.name),
    role: String(userData.role),
    jiraAdmin: "jiraAdmin" in userData ? Boolean(userData.jiraAdmin) : false,
    added: new Date()
  };

  await persistData([...users, newUser]);

  return newUser.id;
};

export const updateUser = async (
  userId: string,
  userData: UserData
): Promise<void> => {
  const user = await getUser(userId);

  if (!user) {
    throw new Error("User not found");
  }

  const updatedUser = {
    ...user,
    name: userData.name !== undefined ? String(userData.name) : user.name,
    role: userData.role !== undefined ? String(userData.role) : user.role,
    jiraAdmin:
      userData.jiraAdmin !== undefined
        ? Boolean(userData.jiraAdmin)
        : user.jiraAdmin
  };

  const users = await fetchData();
  const filteredUsers = users.filter(user => user.id !== userId);

  persistData([...filteredUsers, updatedUser]);
};

export const deleteUser = async (userId: string): Promise<void> => {
  const users = await getUsers();
  const filteredUsers = users.filter(user => user.id !== userId);

  persistData(filteredUsers);
};

There is a lot of code here, but it's a standard typescript.

Step 9: Add error handling middleware

What could be the worse that would happen if the user service gave an error? The whole program would crash. To avoid it, we could add try/catch block in each handler, but there is a better solution - add a middleware before all routes and catch all unexpected errors there.

middlewares/error.ts

export default async ({ response }, next) => {
  try {
    await next();
  } catch (err) {
    response.status = 500;
    response.body = { msg: err.message };
  }
};

Step 10: Add example data

Before we run our program we will add some example data.

db/users.json

[
  {
    "id": "1",
    "name": "Daniel",
    "role": "Software Architect",
    "jiraAdmin": true,
    "added": "2017-10-15"
  },
  {
    "id": "2",
    "name": "Markus",
    "role": "Frontend Engineer",
    "jiraAdmin": false,
    "added": "2018-09-01"
  }
]

That's all. Great! Now we are ready to run our API:

deno -A index.ts

The "A" flag means that we don't need to grant permissions on the program run manually. For development purposes, we will allow all of them. Keep in mind that it wouldn't be safe to do it in the production environment.

You should see a lot of Download and Compile lines, finally we see:

Listening on 4000...

Summary

What did we use:

  • Global Deno object to write to and read files
  • uuid from the Deno standard library to create a unique id
  • oak - a third-party framework inspired by Node.js Koa framework
  • The rest ist pure typescript, objects such as TextEncoder or JSON are standard Javascript objects

How does this differ from Node.js:

  • We don't need to install and configure the typescript compiler or other tools like ts-node. We can just run the program using deno index.ts
  • We import all external modules directly in the code and don't need to install them before we start to implement our application
  • There is no package.json and package-lock.json
  • There is no node_modules in the root directory of the program; our files are stored in a global cache

You can find the full source code here: https://github.com/kryz81/deno-api-example

Do you have any queries? If so, kindly leave a comment below. If you like the article, please tweet it.

Top comments (24)

Collapse
 
dels07 profile image
Deli Soetiawan • Edited

I'm a bit of mixed that Deno doesn't have some kind of package manager, if you import module from master your code will break so soon... if you import using version number you must replace it in all places when you want to update the dependency.

I remember that golang decided to implement dep & modules due former problem.

I think more and more people with use some kind of workaround like this:
github.com/crookse/deno-drash/blob...
github.com/oakserver/oak/blob/mast...

Collapse
 
kryz profile image
Kryz • Edited

Hello Deli,
thank you for your comment. I understand your doubts. In my examples I import dependencies directly from master for simplicity, but there is a solution for the problem you described:

Step 1. Import a specific version instead of master (don't forget to add "v" before the version number):

import { v4 as uuid } from "https://deno.land/std@v0.22.0/uuid/mod.ts";

Step 2. Put this import and all external dependencies into a separate file and re-export them (change "import" from the code above to "export"):

imports.ts

export { v4 as uuid } from "https://deno.land/std@v0.22.0/uuid/mod.ts";

3. Import from imports.ts and not directly from the internet:

import { uuid } from "../imports.ts";

Advantages:

  • Easy management - all your external dependencies are listed in one file
  • You don't need to update many files when you update the version of your dependency
  • You can simply replace an external implementation, but still use the same module name (because of "as uuid")
Collapse
 
opensas profile image
opensas

great answer, I think it should be added to the article

Collapse
 
dels07 profile image
Deli Soetiawan • Edited

I already know your method beforehand, but it would be great if there's cli tool to manage deps.ts to keep things standard.

Thread Thread
 
kryz profile image
Kryz

I think a separate tool like npm won't be added because as the deno docs state: "Deno explicitly takes on the role of both runtime and package manager"

Another solution supported by Deno are file maps: [deno.land/std/manual.md#import-maps]

Collapse
 
steveblue profile image
Stephen Belovarich • Edited

Easy management from the perspective of the person authoring the code, but how is this easily manageable from the perspective of a monorepo where dependencies need to be updated en masse?

Collapse
 
mikevb3 profile image
Miguel Villarreal

im yet to test it, but you can make a import map.

deno.land/std/manual.md#import-maps

Collapse
 
texteditords profile image
Denis Sirbu • Edited

Hi Kryz! The services/createId.ts wasn't working for me I added a function call after uuid

export default () => uuid.generate();

this resolved the sittuation, is this right what I did?

Regards, Denis

Collapse
 
kryz profile image
Kryz

Hello,
yes, thank you! I updated the source code and my example.

Collapse
 
texteditords profile image
Denis Sirbu

Thank you for the article was very interesting!

Regards, Denis.

Collapse
 
sholladay profile image
Seth Holladay

If you need a web server framework for Deno, please give Pogo a try. It is well documented and tested.
github.com/sholladay/pogo

Collapse
 
dharmaram profile image
dharmaram

TS7031 [ERROR]: Binding element 'response' implicitly has an 'any' type.
export default async ({ params, request, response }) => {
~~~~~~~~
at file:///C:/Users/DHARM/deno-js/deno-practice/handlers/updateUser.ts:3:42

Collapse
 
anditsou profile image
Ítalo Sousa

I had the same issue. It's an issue that happens after newer updates at Deno's TS compiler. You should explicitly type those variables. I did this way and it worked for me:

import { Response } from 'https://deno.land/x/oak/mod.ts';

const hello = ({ response }: { response: Response}) => {
    response.body = { message: 'Hello World' };
};

export default {
    hello
}
Collapse
 
ryanlsmith4 profile image
Ryan Smith

So in march of 2020 this tutorial no longer works on mac catalina I get 15 errors first starting with
error TS7031: Binding element 'request' implicitly has an 'any' type.

► file:///~/dev/personalProject/denoStarterApi/handlers/createUser.ts:3:25

3 export default async ({ request, response }) => {

Can we update tutorial or maybe I'm just wrong? literally followed verbatim though

Collapse
 
lexiebkm profile image
Alexander B.K.

Thanks for this article.

I like its use of ES6 module. I want to give it a try, at least for learning.

But I see the following statement in its site :
"A word of caution: Deno is very much under development.
We encourage brave early adopters, but expect bugs large and small."
So we can expect you become one of those brave early adopters. :)

I am waiting for its stable release. For now, I want to enjoy using Node, although I am still relatively new to this javascript web server/run time platform. Currently for my real project, I use PHP + Laravel.

Collapse
 
opensas profile image
opensas

Excellent tutorial, thanks a lot. It looks like deno web framework are somehow in it's infancy stage (which is logical, of course). It seems like there are still many missing pieces (db drivers, orms, testing frameworks, etc...)
I wonder if any of the full-featured framework's authors are working on a deno port (feathers, nestjs, foalts, etc...)

Collapse
 
hshifan profile image
Shifan Hassan

Thank you for the great little tutorial on deno, I managed to complete it and its working great.

Collapse
 
seanmclem profile image
Seanmclem

Is there a decent HTTP Library yet?

 
seanmclem profile image
Seanmclem

Thanks, I'll take a closer look.