A beginners guide to HTTP server in Deno

Mayank C
Tech Tonic

--

The purpose of this article is to cover the absolute basics of writing an HTTP server in Deno. The intended audience is anyone who’s starting up with Deno.

Introduction

Writing an HTTP server is perhaps the first thing to do in server-side runtimes like Deno. The async nature of HTTP request handling makes Deno a good candidate to be a web server, content server, content uploader, API proxy, etc.

Deno’s core runtime comes with a native HTTP server that’s built over Hyper. The Rust based hyper server is a relatively low-level library, meant to be a building block for libraries and applications.

Deno’s core runtime also supports web standard APIs wherever possible. This makes it possible to reapply the skills & code that has been used in the browsers. Deno extends the core HTTP server with web APIs like Request & Response to make it easy for developers.

In this step-by-step article, we’ll build a small HTTP server in Deno that looks up for a user in a local JSON file and returns the user record back.

Building an HTTP server

Deno’s documentation recommends to use standard library’s serve() for building an HTTP server. This utility hides the complexity of the core runtime’s async iterator based listen() & serveHttp(). Unless there is a need to have granular control, serve() should be used in most of the cases.

import { serve } from "https://deno.land/std/http/mod.ts";

The serve() function starts an HTTP server & listens for incoming connections. This function takes two inputs:

  • A handler (sync or async) that would process the request and gives a response that would be sent back to the caller
  • The listening details like IP address & port (“:4000”, “0.0.0.0:4505”, etc.)

The following is an example of an HTTP server that listens on port 5000 with an empty callback handler. This raise a compile-time error as the handler must return a response.

//Codeserve(() => {}, { port: 5000 });//Test$ deno run app.ts
error: TS2345 [ERROR]: Argument of type '() => void' is not assignable to parameter of type 'Handler'.
Type 'void' is not assignable to type 'Response | Promise<Response>'.

Let’s update the code to return an empty response object. This would send a 200 OK without data.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request) {
return new Response();
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000
< HTTP/1.1 200 OK
< content-length: 0

A simple hello world string can be passed directly to the Response object.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request) {
return new Response('Hello world');
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000
< HTTP/1.1 200 OK
< content-length: 11
Hello world

We’ll come back to building Response a bit later. First, we need to do some validations and extract information out of the Request object.

The Request object is available as a parameter, along with a connection info object that contains details about the underlying TCP connection.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
return new Response();
}
serve(reqHandler, { port: 5000 });//Test//req: Request {
bodyUsed: false,
headers: Headers { accept: "*/*", host: "localhost:8000", "user-agent": "curl/7.75.0" },
method: "GET",
redirect: "follow",
url: "http://localhost:5000/"
}
//conn: {localAddr: { transport: "tcp", hostname: "127.0.0.1", port: 5000 }, remoteAddr: { transport: "tcp", hostname: "127.0.0.1", port: 64535 }}

We’ll focus on the Request object and ignore the connection info for this article.

The Request object contains the URL, method, headers, etc. The request body is also there, but it needs to be decoded.

Our HTTP server would support a single path, something like POST /users/search/?userId=user1234. We can use a router module to ease up the work, but for the sake of learning, we’ll do it ourselves. The server code validates the request method i.e. POST. If it’s not POST, the server sends a 405 Method Not Allowed response to the caller.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
return new Response();
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000
< HTTP/1.1 405 Method Not Allowed
$ curl http://localhost:5000 -X PUT
< HTTP/1.1 405 Method Not Allowed
$ curl http://localhost:5000 -X POST
< HTTP/1.1 200 OK
< content-length: 0

The server supports only a specific path: /users/search. The server code validates the path by building a URL object and then using the pathname attribute. Except /users/search, all other paths will get a 404 error.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
const { pathname: path } = new URL(req.url);
if (path !== "/users/search") {
return new Response(null, { status: 404 });
}
return new Response();
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000/users -X POST
< HTTP/1.1 404 Not Found
$ curl http://localhost:5000/users/search?userId=1234 -X POST
< HTTP/1.1 200 OK
< content-length: 0

The server needs to extract userId from query parameters. The query parameters are also inside the searchParams attribute in the same URL object. If userId is not present or empty, the server would send a 400 Bad Request.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
const { pathname: path, searchParams: query } = new URL(req.url);
if (path !== "/users/search") {
return new Response(null, { status: 404 });
}
const userId = query.get("userId");
if (!userId) {
return new Response(null, { status: 400 });
}
return new Response();
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000/users/search -X POST
< HTTP/1.1 400 Bad Request
$ curl http://localhost:5000/users/search?userId= -X POST
< HTTP/1.1 400 Bad Request
$ curl http://localhost:5000/users/search?userId=1234 -X POST
< HTTP/1.1 200 OK
< content-length: 0

All the request validations are done (for now). Next, the server needs to look for userId in a database. For simplicity, we’ll use a local JSON file as database:

$ cat db.json 
{
"1234": {
"Name": "James Bond",
"Age": "100",
"Address": "ABCD"
},
"1235": {
"Name": "Jack Ryan",
"Age": "90",
"Address": "XYZ"
}
}

The server would read this file, parses it, and then look for key={userId}. To read the file, we’ll use Deno.readTextFile() async function. Due to the introduction of an async function, the handler will change to async. If userId is not found in the database, a 204 No Content response would be returned.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
const { pathname: path, searchParams: query } = new URL(req.url);
if (path !== "/users/search") {
return new Response(null, { status: 404 });
}
const userId = query.get("userId");
if (!userId) {
return new Response(null, { status: 400 });
}
const userObj=JSON.parse(await Deno.readTextFile('./db.json'))[userId];
if(!userObj)
return new Response(null, { status: 204 });
return new Response();
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000/users/search?userId=9999 -X POST
< HTTP/1.1 204 No Content
$ curl http://localhost:5000/users/search?userId=1234 -X POST
< HTTP/1.1 200 OK
< content-length: 0

The only thing left is to send a response body when a given userId is found in the database. The Response object supports a variety of content types like text (or string), URL encoded, form data, binary, etc., but not JSON. As the server has to respond with JSON, the userObj needs to be converted to string.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
const { pathname: path, searchParams: query } = new URL(req.url);
if (path !== "/users/search") {
return new Response(null, { status: 404 });
}
const userId = query.get("userId");
if (!userId) {
return new Response(null, { status: 400 });
}
const userObj=JSON.parse(await Deno.readTextFile('./db.json'))[userId];
if(!userObj)
return new Response(null, { status: 204 });
return new Response(JSON.stringify(userObj));
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000/users/search?userId=1234 -X POST
< HTTP/1.1 200 OK
< content-type: text/plain;charset=UTF-8
< content-length: 50
{"Name":"James Bond","Age":"100","Address":"ABCD"}

The response goes with stringified JSON correctly, but the content type header is text/plain. As we passed JSON as a string to the Response object, the content type automatically got set to text/plain. We need to set the content type explicitly.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
const { pathname: path, searchParams: query } = new URL(req.url);
if (path !== "/users/search") {
return new Response(null, { status: 404 });
}
const userId = query.get("userId");
if (!userId) {
return new Response(null, { status: 400 });
}
const userObj = JSON.parse(await Deno.readTextFile("./db.json"))[userId];
if (!userObj) {
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify(userObj), {
headers: {
"content-type": "application/json; charset=UTF-8",
},
});
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000/users/search?userId=1234 -X POST
< HTTP/1.1 200 OK
< content-type: application/json; charset=UTF-8
< content-length: 50
{"Name":"James Bond","Age":"100","Address":"ABCD"}
$ curl http://localhost:5000/users/search?userId=1235 -X POST
< HTTP/1.1 200 OK
< content-type: application/json; charset=UTF-8
< content-length: 47
{"Name":"Jack Ryan","Age":"90","Address":"XYZ"}

The HTTP server works fine. However, as we’re using POST, the userId parameter could also come in the request body instead of the query param. The server would check for the content type header in the Request object. If a request body is supplied & is JSON encoded, it’d be decoded as a JSON. The userId would be checked in both query and body, with body getting precedence over query parameter.

//Codeimport { serve } from "https://deno.land/std/http/mod.ts";async function reqHandler(req: Request, conn: ConnInfo) {
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
const { pathname: path, searchParams: query } = new URL(req.url);
if (path !== "/users/search") {
return new Response(null, { status: 404 });
}
let userId;
if (
req.headers.has("content-type") &&
req.headers.get("content-type")?.startsWith("application/json") &&
req.body
) {
userId = (await req.json()).userId;
}
if (!userId) {
userId = query.get("userId");
}
if (!userId) {
return new Response(null, { status: 400 });
}
const userObj = JSON.parse(await Deno.readTextFile("./db.json"))[userId];
if (!userObj) {
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify(userObj), {
headers: {
"content-type": "application/json; charset=UTF-8",
},
});
}
serve(reqHandler, { port: 5000 });//Test$ curl http://localhost:5000/users/search -X POST -d '{"userId" : "1234"}'
< HTTP/1.1 400 Bad Request
$ curl http://localhost:5000/users/search -X POST -H 'content-type: application/json' -d '{"userId" : "1234"}'
< HTTP/1.1 200 OK
< content-type: application/json; charset=UTF-8
< content-length: 50
{"Name":"James Bond","Age":"100","Address":"ABCD"}
$ curl http://localhost:5000/users/search?userId=9999 -X POST -H 'content-type: application/json' -d '{"userId" : "1234"}'
< HTTP/1.1 200 OK
< content-type: application/json; charset=UTF-8
< content-length: 50
{"Name":"James Bond","Age":"100","Address":"ABCD"}

We’re almost there! The only thing left is to secure the server by adding an API token. The token would be taken out from the Authorization header. For simplicity, server will read a global token from an environment variable AUTH_TOKEN. If either token isn’t supplied or token doesn’t match, a 401 response would be returned.

The final code of server is as follows:

Here are some tests using curl:

$ curl http://localhost:5000
< HTTP/1.1 401 Unauthorized
$ curl http://localhost:5000 -H 'Authorization: Bearer SOMETOKEN'
> Authorization: Bearer SOMETOKEN
< HTTP/1.1 401 Unauthorized
$ curl http://localhost:5000 -H 'Authorization: Bearer D@NO'
> Authorization: Bearer D@NO
< HTTP/1.1 405 Method Not Allowed
$ curl http://localhost:5000/users/search -H 'Authorization: Bearer D@NO' -X POST
> Authorization: Bearer D@NO
< HTTP/1.1 400 Bad Request
$ curl http://localhost:5000/users/search?userId=9999 -H 'Authorization: Bearer D@NO' -X POST
> Authorization: Bearer D@NO
< HTTP/1.1 204 No Content
$ curl http://localhost:5000/users/search?userId=1234 -H 'Authorization: Bearer D@NO' -X POST
> Authorization: Bearer D@NO
< HTTP/1.1 200 OK
< content-type: application/json; charset=UTF-8
< content-length: 50
{"Name":"James Bond","Age":"100","Address":"ABCD"}
$ curl http://localhost:5000/users/search -H 'Authorization: Bearer D@NO' -H 'content-type: application/json' -d '{"userId": "1234"}' -X POST
> Authorization: Bearer D@NO
> content-type: application/json
> Content-Length: 18
< HTTP/1.1 200 OK
< content-type: application/json; charset=UTF-8
< content-length: 50
{"Name":"James Bond","Age":"100","Address":"ABCD"}

That’s all about the basics of writing an HTTP server in Deno. Here are some additional resources:

  • To know more about writing HTTP servers in Deno, please check the comprehensive guide here
  • To know more about writing HTTP clients (aka making HTTP requests) in Deno, please check the comprehensive guide here

--

--