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)
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...
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):
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
3. Import from imports.ts and not directly from the internet:
Advantages:
great answer, I think it should be added to the article
I already know your method beforehand, but it would be great if there's cli tool to manage deps.ts to keep things standard.
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]
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?
im yet to test it, but you can make a import map.
deno.land/std/manual.md#import-maps
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
Hello,
yes, thank you! I updated the source code and my example.
Thank you for the article was very interesting!
Regards, Denis.
If you need a web server framework for Deno, please give Pogo a try. It is well documented and tested.
github.com/sholladay/pogo
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
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:
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
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.
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...)
Thank you for the great little tutorial on deno, I managed to complete it and its working great.
Is there a decent HTTP Library yet?
Thanks, I'll take a closer look.