Building a server with Oak | Deno.

In this article, we are going to build a simple web server using Oak.

The web server will serve files, and perform CRUD operations. And we will use DenoDB as an ORM.

Requirements

  • Deno installed

What is Oak?

Oak is a middleware framework for Deno’s native HTTP server, Deno Deploy, and Node.js 16.5 and later. It also includes a middleware route. This middleware framework is inspired by Koa.

Project structure

oak-demo/
    file/
        index.html
    controllers/
        Controllers.ts
    model/
        Person.ts
    db.ts
    main.ts

Hello World

main.ts

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

const router = new Router();
router.get("/", (ctx) => {
  ctx.response.body = "Hello World"
});

const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

app.listen({ port: 8080 });

Here, we import Application and Router classes and create a Router() instance.

We use the get() method and pass to it a path and a handler. The response body is a "Hello World" message.

Then we create an Application() instance. And use the use() method to register our middleware. Finally, we use the listen() method, to start listening for requests.

Serving Files.

main.ts

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

const ROOT_DIR = "./file";

const app = new Application();

app.use(async (ctx) => {
  await send(ctx, "/index.html", {
    root: ROOT_DIR,
  });
});

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

app.listen({ port: 8080 });

In this snippet, we use the send() function to serve a file from the local file system. We pass it the file's path and the root directory.

Deno requires read permission for the root directory.

Deno run --allow-net --allow-read main.ts

Deno DB

We create a model folder in our root directory and create a file called Person.ts .

Person.ts

import { DataTypes, Model} from 'https://deno.land/x/denodb@v1.2.0/mod.ts';

export class Person extends Model {
    static table = "person";
    static timestamps = true;

    static fields = {
        id: { primaryKey: true, autoIncrement: true },
        firstName: DataTypes.STRING,
        lastName: DataTypes.STRING,
    };
}

In Person.ts we need to import Datatypes and Model classes from DenoDB and then create a Person class, that inherits the properties and methods from the Model class.

We named our table "person" and we mark timestamps as true to show the time when a row is created and updated.

Then, we define the fields of our model and its data types, in this case: id, firstName, and lastName.

After that, we create a file in our root directory where we going to connect the database. I going to use SQLite, but DenoDB has support for PostgreSQL, MySQL, MariaDB, and MongoDB.

db.ts

import { Database, SQLite3Connector } from 'https://deno.land/x/denodb@v1.2.0/mod.ts';

import {Person} from './model/Person.ts';


const connector = new SQLite3Connector({
    filepath: "./database.sqlite",
});

export const db = new Database(connector);

db.link([Person])

export default db

We need to import Database and SQLite3Connector from Deno Db and Person class from the model module.

We create an immutable variable to store an instance of the SQLite connector and specify the path of the SQL file. Then, we create an immutable variable to store an instance of a database class, connect our database, and after that, we link it to the model.

Now we create a controllers folder in the root directory and create a file to define our controllers.

Controllers.ts

In this file we define our handlers to perform CRUD operations. We will define 5 handlers, one to perform POST requests, one to perform PUT requests, one to perform DELETE requests, and two to perform GET requests.

POST request

import { Context, Status } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import {Person} from '../model/Person.ts';


export const CreatePerson = async (ctx: Context) => {
    const person = await ctx.request.body({type:"json"}).value;

    try {

        const createdPerson = await Person.create(person);

        ctx.response.body = createdPerson;
        ctx.response.type = "json";
        ctx.response.status = Status.Created;

    } catch( _err) {

         ctx.response.status = Status.InternalServerError;
    }
};

Here we create a variable to store the request body and pass it as an argument to the create method, to create a record in our database.

If the operation is successful, it will send the status code 201 as a response and Internal Server Error if it's not.

GET requests

export const AllPersons = async (ctx: Context) => {

    try {
        const person =  await Person.all()
        ctx.response.body = person;
        ctx.response.type = "json";
        ctx.response.status = Status.OK;

      } catch(_err) {

        ctx.response.status = Status.InternalServerError;
      }
};

In AllPersons handlers we retrieve all the records in the database using the all() method and store them in the variable person. The handler sends all the records and 200 status code as a response.

If there's an error, the handler will send an Internal Server Error as a status code.

export const GetPerson = async (ctx: Context) => {
    const id = ctx.params.id

    try {
        const person = await Person.where('id', id).first()

        if (!person) {

            ctx.response.status = Status.NotFound

        } else {
            ctx.response.body = person;
            ctx.response.type = "json";
            ctx.response.status = Status.OK;

        }  

    } catch (_err) {

      ctx.response.status = Status.InternalServerError;

    } 
}

Here, we extract the parameter id from the path and store it in a variable. Then, we pass the id as an argument to the where() method, to retrieve the record that matches the ID passed and send it as a response with a 200 status code. If there is no record that matches the ID passed, the handler will send a Not Found status code.

PUT requests

export const UpdatePerson = async (ctx: Context) => {
  const id = ctx.params.id
  const reqBody = await ctx.request.body().value;

  try {
      const person = await Person.where('id', id).first()

      if (!person) {
      ctx.response.status = Status.NotFound

      } else {

        await Person.where('id', id).update(reqBody)
        ctx.response.body = "Person Updated";
        ctx.response.type = "json";
        ctx.response.status = Status.OK;

      }

    } catch (_err) {
        ctx.response.status = Status.InternalServerError;

  }     

};

In UpdatePerson handler, we extract the id from the path and the request body. Then, we use the where() method to look for a record that matches the ID. If the operation is successful, we pass the request body as an argument to the update() method. After the record is modified, the handler sends "Person Updated" as a response body and 200 as a status code.

DELETE requests

export const DeletePerson = async  (ctx: Context) => {
    const id = ctx.params.id

    try {
        const person = await Person.where('id', id).first()

        if (!person) {

            ctx.response.status = Status.NotFound

        } else {
            Person.delete()
            ctx.response.body = "Person deleted";
            ctx.response.type = "json";
            ctx.response.status = Status.OK;

        }     
    } catch (_err) {

      ctx.response.status = Status.InternalServerError;

    }        

};

Here we extract the id from the path and retrieve a record that matches the ID, then we use the delete() method to delete the record. After the record is deleted, the handler sends "Person deleted" as a response body, and 200 as a status code.

main.ts

import { Application,Router, send } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import {db } from "./db.ts"
import {AllPersons, GetPerson, CreatePerson, UpdatePerson, DeletePerson} from "./controllers/Controllers.ts"

const ROOT_DIR = "./file";

const app = new Application();

const router = new Router();

router.get("/persons", AllPersons)
router.get("/person/:id", GetPerson);
router.post("/person", CreatePerson);
router.put("/person/:id", UpdatePerson);
router.delete("/person/:id", DeletePerson);

app.use(router.routes());

app.use(async (ctx) => {

  await send(ctx, "/index.html", {
    root: ROOT_DIR,
  });
});

await db.sync()

app.listen({ port: 8080 });

In main.ts we import all our handlers from Controller.ts. Then we create a router instance and define our routes. For every HTTP method, we define a path and pass the handler it will call.

We use the use() method to add our routes as middleware.

We use sync() method to start Deno DB.

To run the server we write on our command line:

deno run --allow-all main.ts

Adding Logging

...
import logger from "https://deno.land/x/oak_logger/mod.ts";

...

app.use(logger.logger);
app.use(router.routes());
...


app.listen({ port: 8080 });

To add logging to our server, we import the module oak_logger . And add logger.logger as middleware on top of routes. Now, every time a request is made to our server, it will show the loggings in our command line. If we add the logger after app.use(router.routes()); , it will not show the loggings of the routes.

Conclusion

I found Oak easy to use, and its documentation helps a lot, it is well-written. I find it very similar to Gin even when they are written in different languages. Also, you can visit the awesome-oak page, it has a list of community projects for Oak.

Thank you for taking the time to read this article.

If you have any recommendations about other packages, architectures, how to improve my code, my English, or anything; please leave a comment or contact me through Twitter, or LinkedIn.

The source code is here.

Reference

Oak Documentation.

Oak's GitHub page.

Static file in Deno Oak.

Awesome-oak.