Creating a small Deno API

In this post I would like to tell and show the process of creating a small API using Deno. Deno is the latest Javascript and Typescript launcher developed by Node.js creator Ryan Dahl.



Our goals :

  • Develop an API that will work with user data
  • Provide the ability to use the GET, POST, PUT, and DELETE methods
  • Save and update user data in local JSON file
  • Use the framework to speed development

The only thing we need to install is Deno. Deno supports Typescript right out of the box. For this example, I used Deno version 0.22 and this code may not work on future versions.
The version of the installed Deno can be found out with the deno version command in the terminal.

Program structure


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

As you can see, it looks like a small web application on Node.js.

  • handlers - contains route handlers
  • middlewares - contains functions that will be launched on every request
  • models - contains the designation of models, in our case it is only a user interface
  • services - contains ... services!
  • config.ts - application configuration file
  • index.ts is the entry point for our application
  • routing.ts - contains API routes

Framework selection


There are many great frameworks for Node.js. One of the most popular is Express . There is also a modern version of Express'a - Koa . Unfortunately, Deno does not support Node.js libraries and the choice is much smaller, but there is a framework for Deno, which is based on Koa - Oak . We will use it for our small project. If you’ve never used Koa, don’t worry, it looks a lot like Express.

Creating an application entry point


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 one of the main Deno chips - importing modules directly from the Internet. After that, there is nothing unusual: we create an application, add middleware, routes and, finally, start the server. Everything is the same as when using Express or Koa.

Create 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 file. Settings are transferred from the launch environment, but we will also add default values. The Deno.env () function is an analogue of process.env in Node.js.

Adding a user model


models / user.ts

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

Creating a Route File


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 unusual. We created a router and added several routes to it. It looks almost like you copied code from an Express application, right?

Event handling for routes


handlers / getUsers.ts

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

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

Returns all users. If you have never used Koa, then I’ll explain. The response object is analogous to res in Express. The res object in Express has a couple of methods like json or send , which are used to send a response. In Oak and Koa, we need to set the value that we want to return 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;
};

Everything is easy here too. The handler returns the user with the desired 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 is responsible for creating the user.

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 handler checks if the user with the specified ID exists and updates the 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" };
};

Responsible for deleting a user.

It is also advisable to process requests for non-existent routes and return an error message.

handlers / notFound.ts

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

Adding Services


Before creating services that will work with user data, we need to make two small auxiliary services.

services / createId.ts

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

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

Each new user will receive a unique id. We will use the uuid module from the Deno standard library to generate a random number.

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 will help interact with the JSON file in which user data will be stored.
To get all users, read the contents of the file. The readFile function returns an object of type Uint8Array , which must be converted to a String type before entering into a JSON file .

And finally, the main service for working with 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 this is pure Typescript.

Error processing


What could be worse than what would happen in the event of a service error working with user data? The whole program may crash. To avoid this scenario, you can use the try / catch construct in each handler. But there is a more elegant solution - add middleware in front of each route and prevent any unexpected errors that may occur.

middlewares / error.ts

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

Before starting the program itself, we need to add data to run.

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! Now you can try to run our application:

deno -A index.ts

The “A” flag means that the program does not need to be given any separate permissions. Do not forget that using this flag is unsafe in production.

Most likely, you will see many lines with download (Download) and compilation (Compile). In the end, the coveted line should appear:

Listening on 4000

To summarize


What tools did we use?

  1. Deno global object for reading / writing files
  2. uuid from the Deno standard library to create a unique id
  3. oak - a third-party framework inspired by Koa for Node.js
  4. Pure Typescript, TextEncode or JSON objects included in Javascript

What is the difference from Node.js?


  • No need to install and configure the compiler for Typescript or other tools like ts-node. You can simply run the program with the deno index.ts command
  • Inclusion of all third-party modules directly in the code without the need for preliminary installation
  • Lack of package.json and package-lock.json
  • The absence of node_modules in the root directory of our program. All downloaded files are located in the global cache

If necessary, the source code can be found here .

Source: https://habr.com/ru/post/undefined/


All Articles