Routing

Overview

Routing defines how an application’s endpoints (URIs) respond to client requests. It's a set of routes When building web applications we can handle routing on the server and on the client. Those two approaches are not exclusive and are usually used together. On this page we describe the server-side routing as client-side routing is tightly related to the UI library or framework.

In Kretes routing is defined through a plain object, that is an exisiting data structure in JavaScript (data-driven approach). This routing object maps HTTP methods (e.g. GET, POST, etc) to routes that are, in turn, mappings between paths and functions (called handlers).

// plain JavaScript object
{
  // a HTTP method
  GET: {
    '/dashboard': request => {
      return { ... }
    }
  }
}

The application listens for requests that match the specified path (e.g. /dashboard) + method (e.g. GET), and when there is a match, it trigger the specified handler function.

Handlers

Contrary to Express.js (and similar frameworks), a handler in Kretes is a one argument function. This argument is the incoming request. The return value is used by Kretes to create an HTTP response.

// An example of a handler
const browse = request => {
  return { ... } // <- an HTTP response
}

In Express, and the majority of other Node.js frameworks, handlers take two arguments. The first one is the request and the second one is the response.

In Kretes, the response is simply everything that is being returned by the handler. This way, it may be slightly more natural to think about the process of handling requests and generating responses: handlers are functions, which take requests as their input and produce responses as their output. The response is represented as a JavaScript object which must have at least the body key.

const fetch = request => {
  return { body: 'Hello, Kretes!' }
}

The return value can be a string. In that case the response is 200 OK with the Content-Type header set to text/plain, e.g.

const say = request => {
  return 'This is nice'
}

Usually the value returned by a handler is an object with (at least) the body property. Optionally, you can also specify the headers, statusCode or type properties. This constitutes the Handler type.

import { Handler } from 'kretes';
const fetch: Handler = request => {
  return {
    body: '<h1>Hello World</h1>',
    type: 'text/html',
    statusCode: 200,
    headers: {}
  }
}

Kretes uses plain objects (a regular data structure in JavaScript) to represent HTTP responses. That's why we say it's a data-driven (and declarative) approach. This is inspired by the ring library from the Clojure community.

In some relatively rare cases, the response can be also a stream. Kretes sets the type automatically to application/octet-stream in that event.

Parameters

There are two kinds of parameters possible in a web application: the ones that are sent as part of the URL after ?, called query string parameters; and the ones that are sent as part of the request body, referred to as POST data (usually comes from an HTML form or as JSON). Kretes does not make any distinction between query string parameters and POST parameters, both are available in the request params object.

Wrappers For Common HTTP Responses

It would be arduous to create an object with the specific fields each time an HTTP response is needed. Kretes provides convenient wrappers in that situation.

Instead of writing:

import { Handler } from 'kretes';

const fetch: Handler = request => {
  return {
    body: '<h1>Hello World</h1>',
    type: 'text/html',
    statusCode: 200,
    headers: {}
  }
}

you can use the HTMLString wrapper and write this:

import { Handler } from 'kretes';
import { HTMLString } from 'kretes/response';

const fetch: Handler = request => {
  return HTMLString('<h1>Hello World</h1>')
}

Set The Preferred Response Format

Kretes determines the preferred response format from either the HTTP Accept header or format query string parameter, submitted by the client. The format query parameter takes precedence over the HTTP Accept header.

Based on the preferred format, you can construct actions that handle several possibilities at once using just the JavaScript's switch statement - no special syntax needed.

const browse = ({ format }) => {
  // ... the action body

  switch (format) {
    case 'html':
      // provide a response as a HTML Page
      return HTMLString(...)
    case 'csv':
      // provide a response as in CSV format
      return CSVPayload(...)
    default:
      // format not specified
      return JSONPayload(...)
  }

}

Reusable workflows / Middlewares

Handlers can be composed from simple functions so that the shared bevahior can be extracted into reusable chunks of code. Those functions are equivalent to Express.js/Koa middlewares. In order to define a workflow, you need to map an array of functions with a handler at the end to the specific path.

{
  GET: {
    '/dashboard': [workflow_1, workflow_2, handler]
  }
}

Those functions are composed from left to right so that the declaration above is equal to the following one:

{
  GET: {
    '/dashboard': workflow_1(workflow_2(handler))
  }
}

Such composition creates workflows that can contain validation, logging, profiling, permission checking or throttling. Here's an example of a simple validation that checks if the request parameters contain the admin field of the type String.

import { validate } from 'kretes/request';

{
  GET: {
    '/request-validation': [
      validate({ name: { type: String, required: true } }),
      ({ params: { admin } }) =>
        `Admin param (${admin}) should be absent from this request payload`
    ]
  }
}

Those workflows are local for the particular path, contrary to Express.js that wraps every middleware around every path. In order to replicate that behaviour you can also add global middlewares using the use method.

let id = 0, sequence = () => id++

app.use(async (context, next) => {
  const { request } = context
  request.id = sequence()
  return next(context)
})

Found a mistake?