Papupata Documentation

Guide: middleware

Overview

Middleware provides powerful additional functionality to how APIs behave. This guide goes through how middleware is used and created.

Table of contents

What is middleware?

Much like express middleware, papupata middleware exists to separate common functionality needed by APIs from the actual API implementations.

There are two types of middleware:

  • Middleware inherent to an API declaration, which is applied to all of the declared APIs. This could be called global middleware, but since it only applies to a single API declaration it could be misleading.
  • API-specific middleware, which is only applied when explicitly requested.

The middleware generally speaking do one or all of the following things:

  • Manipulate the request before passing it along to the API implementation
  • Preventing the API from being called under specific circumstances
  • Manipulate the output of an API
  • Add a side-effect to an API being invoked

Using middleware

Both inherent and route-specific middleware is given in an array. The order of the elements matters: inherent middleware is always processed before route-specific middleware, and within the array all middleware are processed in the same order as they exist in the array. This can be important if, say, one middleware adds data to request another middleware depends on, or if you want to block the execution of the middleware as well based on a condition.

Configuring inherent middleware:

Older styles are still supported in later versions, just not preferred.
API.updateConfig({
  inherentMiddleware: [myMiddleware1, myMiddleware1]
})

Configuring middleware for a single route:

const myAPI = API.declareGetAPI('/test').response<string>()

myAPI.implementWithPapupataMiddleware([myMiddleware1, myMiddleware1], implementation)

You can use express middleware with papupata, see more in the express section of this guide.

The most basic middleware

Let's start by creating a middleware that simply logs the URLs being invoked.

import {PapupataMiddleware} from 'papupata'
import {Request} from 'express'
const myMiddleware: PapupataMiddleware<Request, void> = (req, res, api, next) => {
    console.log('Handling', req.url)
    return next()
}

The main difference between express and papupata middleware is that while express middleware forms a list, papupata middleware are more like koa middleware in that there is a call stack, or a pyramid where each middleware can be a part of both the request and the response. The important thing for most middleware is to return the value returned by calling next() instead ignoring it.

Errors are handled by throwing exceptions.

Manipulating request

In theory manipulating the request is just changing data in it.

const myMiddleware: PapupataMiddleware<Request, void> = (req, res, api, next) => {
    if (req.body?.userId === 'self') {
        req.body.userId = req.session.userId
    }
    return next()
}

Typescript does make some kinds of changes more difficult. If you want to add custom fields, you probably want to extend the request interface to contain whatever you are adding.

Sometimes though these modifications might only be relevant for single papupata API declarations and you don't want to change the global request interface. For this reason papupata supports changing the request type to something other than the default.

interface MyRequest extends Request {
  myField: string
}

const API = new APIDeclaration<MyRequest>()

 const myMiddleware: PapupataMiddleware<MyRequest, void> = (req, res, api, next) => {
  if (req.body?.userId === 'self') {
      req.body.userId = req.session.userId
  }
  return next()
                }

In the example both the implementations and middleware on API use the MyRequest instead of the normal Request type for the request parameter.

Abandoning the request

Sometimes middleware needs to prevent the actual implemementation from being called. There are multiple way to do this depending on what your goal is. There is always one thing in common though: you don'd call next.

  • Return a value. It then becomes the response instead of what the implementation would have provided
    const myMiddleware: PapupataMiddleware<MyRequest, void> = (req, res, api, next) => {
      if (!req.headers.accept?.includes('application/json')) {
          res.status(400)
          return 'Bad headers'
      }
      return next()
    }
    
  • Throw an error to do normal error handling
    const myMiddleware: PapupataMiddleware<MyRequest, void> = (req, res, api, next) => {
      if (!req.headers.accept?.includes('application/json')) {
          throw new Error('Bad headers')
      }
      return next()
    }
    
  • Explicitly send a response using the methods on res. This prevents other middleware from being able to affect the response.
    const myMiddleware: PapupataMiddleware<MyRequest, void> = (req, res, api, next) => {
      if (!req.headers.accept?.includes('application/json')) {
          res.status(400)
          res.send('Bad request: Invalid headers')
          return
      }
      return next()
    }
    
  • Return skipHandlingRoute to have express resume routing and middleware processing with other APIs.
    import {skipHandlingRoute} from 'papupata'
    const myMiddleware: PapupataMiddleware<MyRequest, void> = (req, res, api, next) => {
      if (!req.headers.accept?.includes('application/json')) {
          return skipHandlingRoute
      }
      return next()
    }
    

Manipulating response

Normally a middleware gets the response data by doing await next(), and it can then do whatever manipulation it desires. Some APIs can send the response directly though, in which case this is not true, so you should try and be prepared for it.

const myMiddleware: PapupataMiddleware<MyRequest, void> = async (req, res, api, next) => {
  const value = await next()
  if (!res.headersSent) {
    res.status(204)
    return value ?? 'No data'
  }
  return value
}

Route options

It'd be really convenient to give middleware some metadata about a route. Is unauthenticated access permitted? Does it handle unusual payloads? Papupata allows exactly that.

When you create an API declaration you can provide an interface that provides options to routes, and provide a value of that type to the APIs.

import {Request} from 'express'
interface Options { publicAccess: boolean }
const API = new APIDeclaration<Request, Options>()
const myAPI = API.declareGetAPI('/api/ping', {publicAccess: true}).response<string>()

This value can then be accessed by middleware.

const myMiddleware: PapupataMiddleware<Request, Options> = async (req, res, api, next) => {
  if (!api.options?.publicAccess && !req.session.isValid) {
    throw new Error('Authentication required')
  }
  return next()
}

Error handling

You can use middleware to add custom error handling.

const myMiddleware: PapupataMiddleware<Request, Options> = async (req, res, api, next) => {
  try {
    return await next()
  } catch(err) {
    res.status(err.status || 400)
    return err.message
  }
}

Express middleware

Individual routes can be given express middleware in addition to papupata middleware. If you do that, the express middleware is run before the papupata middleware is -- even the inherent middleware.

myAPI.implementWithExpressMiddleware([expressMiddleware], implement)
myAPI.implementWithMiddleware({express: [expressMiddleware]}, implement)

A generally preferable option is to convert express middleware into papupata middleware; convertExpressMiddleware is exported by papupata to do that. At this time it does not handle express middleware that handle errors, but other middleware should be convertable.

import {convertExpressMiddleware} from 'papupata'
myAPI.implementWithPapupataMiddleware([convertExpressMiddleware(expressMiddleware)], implement)