CS 312 Software Development

Lecture 11 - Servers

Implementing the REST API

We are going to implement our server in JavaScript. There is no requirement we do so. Since we are using HTTP as the interface any system that provides a HTTP server would do. However, one of the key selling points of Node (JavaScript outside of the browser) is that we can use the same programming language on the server as we do on the client, potentially simplifying the development process.

Node is not just a JavaScript interpreter. It implements an asynchronous event loop focused on I/O operations and series of modules for interacting with the file system, HTTP, DNS, etc... Like the browser, Node is single threaded, but achieves high throughput by maintaining high utilization. Long latency operations are asynchronous so that the main thread can continue with other work. This architecture does mean that computationally intensive operations can monopolize the main thread (the same can happen in the browser).

Simple Node Server

Creating a HTTP server with Node is as simple as:

const http = require('http');
const server = http
  .createServer((request, response) => {
    response.writeHead(200, { 'Content-Type': 'text/plain' });
    response.end("Don't Panic");
  })
  .listen(5042);
console.log('Listening on port %d', server.address().port);

createServer launches a HTTP server and invokes the supplied handler on any request. Note that here we ignore the request and always return the same response.

With this low-level interface we are responsible for everything: interpreting the request and building the entire response. As you expect there is an opportunity for frameworks that implement the common features of a web server.

Next.js

One of the cool features of Next.js is the support for API Routes. These allow us to implement a server based API without actually writing our own server.

Creating a file under the directory pages/api/ will create a route at /api/.

Next expects each of these routes to export a handler function -- one that takes in two arguments, a request and a response.

Here is a simple example. We can create a file pages/api/message.js, and put this inside.

export default (req, res) => {
  res.statusCode = 200;
  res.json({ message: "Don't Panic" });
};

If we then visited our site at localhost:3000/api/messages, we would receive the message as a JSON string.

This has the same structure as the bare version above, but it is specific to a particular route and has some convenience features (like the json function which converts an object into JSON, loads it into the body and sets the headers appropriately).

Dynamic routes

next.js supports something called dynamic routes. These routes allow us to parameterize our routes. This is how we implement routes like /api/films/:id, where we aren't going to create a unique endpoint for every single possible film.

To create a dynamic route, we just have to make a file with square brackets around the name. That name then is basically a wildcard, accepting any value, which is then passed in as part of the request object.

Example We add a file to our site at src/pages/api/films/[id].js. This will now provide the endpoints for our /api/films/:id routes.

Here is the code for the route (which only supports the GET endpoint):

import films from "../../../data/films.json";


export default (req, res)=>{
  const {query: {id}} = req;

  const film = films.find((film)=>film.id===+id);

  res.json(film);
}

The main thing to notice here is that we are extracting the id from the request object. The funny syntax is because we are destructuring two objects simultaneously: req is an object with a property called query, and the value of the query property is another object that contains a property called id.

To see a running instance, visit https://server-example.profandrews.repl.co

The Replit can be seen at https://replit.com/@profAndrews/server-example

next-connect

While Next.js effectively handles the routes for us by putting handlers in different files, we still need to worry about the endpoints (i.e., the different verbs that apply to each route).

The basic way to do that is to look at req.method for the verb.

const handler =  (req, res) => {
    const { id } = req.query;
    if (req.method === 'GET'){
      // ...
    } else if (req.method === 'PUT'){
       // ...
    }else if (req.method === 'DELETE'){
       // ...
    }
  }


export default handler;

However, we are going to make use of another library called next-connect, which allows us to further break down our handlers into individual endpoints.

import nc from 'next-connect';

const handler = nc()
  .get(async (req, res) => {
    const { id } = req.query;
// ...
  })
  .put(async (req, res) => {
    const { id } = req.query;
   // ...
  })
  .delete(async (req, res) => {
    const { id } = req.query;
    // ...
  });

export default handler;

Middleware

We can extend our server with middleware to add functionality. For example, Next.js includes some default middleware for parsing JSON data found in request bodies into JavaScript objects.

Here is an example that adds two pieces of middleware to our handler:

import nc from ‘next-connect';
import Cors from 'cors';

export function onError(error, req, res) {
  console.error(error);
  res.status(500).end(error.toString());
}


export const cors = Cors({
    methods: ['GET', 'PUT', 'POST', 'DELETE'],
  origin: '*',
  allowedHeaders: ['Content-Type']
  })



const handler = nc({onError})
.use(cors)
  .get(async (req, res) => {
    const { id } = req.query;
// ...
  })
  .put(async (req, res) => {
    const { id } = req.query;
   // ...
  })
  .delete(async (req, res) => {
    const { id } = req.query;
    // ...
  });

export default handler;

The first piece of middleware is a basic error handler that will be at the end of the chain. If nothing closes the request or something raises an error, we fall back on this function, which reports a server error (status code 500), and send the error message along with it. In truth, this is a pretty bare bones error handler, but it works for our purposes.

Because the error handler is a little bit special, we pass it into the next-connect handler as a configuration option (as onError).

The other piece of middleware here, handles CORS requests. It allows us to specify which Cross-Origin requests we will allow. In this case, we are using the Cors library which will build the middleware handler function for us, we just need to configure it. This shows us the more common approach for adding middleware -- calling use on the handler.

Middleware as a design pattern (and Aspect Oriented Programming)

Middleware is an example of a design pattern for implementing "cross cutting" concerns. Each middleware has access to the request, the response and the next middleware in the chain. Invoking send terminates the chain (and sends a response), while calling next() passes the request (and response) objects to the next middleware in the chain. With the middleware pattern we build up a complex application from many small transformations to the request (or response).

Moreover those transformations aren't applied to a single request (as in the request handlers above), but many different kinds of requests (hence "cross cutting concerns"). For example, in many applications most routes require the user to login. Instead of introducing this check in each route, we can do so with a middleware that will redirect all but a few specific un-authenticated requests to the login page.

"Cross cutting" concerns are those that affect many parts (or concerns) of a program not just one. Aspect Oriented Programming is a set of techniques to implement these "cross cutting" concerns while maintaining modularity and maintainability. We will see other examples of "cross cutting" concerns soon, notably in implementing validations for models (in the MVC sense).


Last updated 04/13/2021