purescript-httpurple/docs/Middleware.md
2022-08-25 12:02:28 +01:00

10 KiB

Writing and Using Middleware in HTTPurple 🪁

Since HTTPurple 🪁 routers are just pure functions, you can write a middleware by simply creating a function that takes a router and an HTTPure.Request, and returns an HTTPure.ResponseM. You can then simply use function composition to combine middlewares, and pass your router to your composed middleware to generate the decorated router!

See the Middleware example to see how you can build, compose, and consume different types of middleware.

🎉 New:

HTTPurple 🪁 now supports extensible middlewares that allows you to add further data to your request, see Extensible Middlewares.

HTTPurple 🪁 now supports Node.js/Express middlewares, see Node.js Middlewares.

Writing Middleware

A middleware is a function with the signature:

(HTTPure.Request -> HTTPure.ResponseM) -> HTTPure.Request -> HTTPure.ResponseM

Note that the first argument is just the signature for a router function. So essentially, your middleware should take a router and return a new router. That's it! You can do pretty much anything with middlewares. Here are a few examples of common middleware patterns:

You can write a middleware that wraps all future work in some behavior, like logging or timing:

myMiddleware router request = do
  doSomethingBefore
  response <- router request
  doSomethingAfter
  pure response

Or perhaps a middleware that injects something into the response:

myMiddleware router request = do
  response <- router request
  HTTPure.response' response.status response.headers $
    response.body <> "\n\nGenerated using my super duper middleware!"

You could even write a middleware that handles routing for some specific cases:

myMiddleware _ { path: [ "somepath" ] } = HTTPure.ok "Handled by my middleware!"
myMiddleware router request = router request

Or even a middleware that conditionally includes another middleware:

myMiddleware router = if something then someOtherMiddleware router else router

Just make sure your middlewares follow the correct signature, and users will be able to compose them at will!

Note that because there is nothing fancy happening here, you could always write higher order functions that don't follow this signature, if it makes sense. For instance, you could write a function that takes two routers, and selects which one to use based on some criteria. There is nothing wrong with this, but you should try to use the middleware signature mentioned above as much as possible as it will make your middleware easier to consume and compose.

Consuming Middleware

Consuming middleware easy: simply compose all the middleware you want, and then pass your router to the composed middleware. For instance:

main = HTTPure.serve port composedRouter $ Console.log "Server is up!"
  where
    composedRouter = middlewareA <<< middlewareB <<< middlewareC $ router

Be aware of the ordering of the middleware that you compose--since we used <<<, the middlewares will compose right-to-left. But because middlewares choose when to apply the router to the request, this is a bit like wrapping the router in each successive middleware from right to left. So when the router executes on a request, those middlewares will actually execute left-to-right--or from the outermost wrapper inwards.

In other words, say you have the following HTTPurple 🪁 server:

middleware letter router request = do
  EffectClass.liftEffect $ Console.log $ "Starting Middleware " <> letter
  response <- router request
  EffectClass.liftEffect $ Console.log $ "Ending Middleware " <> letter
  pure response

main = HTTPure.serve port composedRouter $ Console.log "Server is up!"
  where
    composedRouter = middleware "A" <<< middleware "B" $ router

When this HTTPurple 🪁 server receives a request, the logs will include:

Starting Middleware A
Starting Middleware B
...
Ending Middleware B
Ending Middleware A

Extensible Middlewares

The base type for requests, ExtRequest is now extensible:

type RequestR route ext =
  ( method :: Method
  , path :: Path
  , query :: Query
  , route :: route
  , headers :: RequestHeaders
  , body :: RequestBody
  , httpVersion :: Version
  , url :: String
  | ext
  )
type ExtRequest route ext = { | RequestR route ext }

and the old Request is just a type alias for an extensible request without any further data:

type Request route = { | RequestR route () }

This allows us to write middlewares that extend our request with additional information.

E.g. we can write an authenticator middleware that adds user information to the request:

authenticator ::
  forall route extIn extOut.
  Nub (RequestR route extOut) (RequestR route extOut) =>
  Union extIn (user :: Maybe String) extOut =>
  Middleware route extIn extOut
authenticator router request@{ headers } = case Headers.lookup headers "X-Token" of
  Just token | token == "123" -> router $ merge request { user: Just "John Doe" }
  _ -> router $ merge request { user: Nothing :: Maybe String }

The type Middleware is defined as

type MiddlewareM m route extIn extOut = (ExtRequest route extOut -> m Response) -> ExtRequest route extIn -> m Response
type Middleware route extIn extOut = MiddlewareM Aff route extIn extOut

and adds the extOut extension to our request handler. In our case, it adds (user :: Maybe String) to the request handler. extIn defines the input extension that the middleware receives. At the root level, this will be an empty row () (unless we use a node middleware). Not fixing extIn to () however allows us to stack our middlewars.

Let's add another middleware, that adds the request time to the request:

requestTime ::
  forall route extIn extOut.
  Nub (RequestR route extOut) (RequestR route extOut) =>
  Union extIn (time :: JSDate) extOut =>
  Middleware route extIn extOut
requestTime router request = do
  time <- liftEffect JSDate.now
  router $ merge request { time }

Similar to the authenticator middleware, we add a new field time to the request.

We can now compose our middleware stack:

middlewareStack :: forall route. (ExtRequest route (user :: Maybe String, time :: JSDate) -> ResponseM) -> Request route -> ResponseM
middlewareStack = authenticator <<< requestTime

We can now use our middleware stack. Let's define a simple route:

data SayHello = SayHello

derive instance Generic SayHello _

sayHelloRoute :: RD.RouteDuplex' SayHello
sayHelloRoute = RD.root $ RG.sum
  { "SayHello": RG.noArgs
  }

and a router that makes use of our newly added information:

-- | Say 'hello <USER>' when run with X-Token, otherwise 'hello anonymous'
sayHello :: ExtRequest SayHello (user :: Maybe String, time :: JSDate) -> ResponseM
sayHello { user: Just user, time } = ok $ "hello " <> user <> ", it is " <> JSDate.toDateString time <> " " <> JSDate.toTimeString time
sayHello { user: Nothing, time } = ok $ "hello " <> "anonymous, it is " <> JSDate.toDateString time <> " " <> JSDate.toTimeString time

As you can see, we are now using an ExtRequest with the additional information (user :: Maybe String, time :: JSDate), which we can use in our function body.

Finally, we wrap our sayHello router with our middlewareStack:

main =
  serve { hostname: "localhost", port: 8080 } { route: sayHelloRoute, router: middlewareStack sayHello }

See the full example in the Examples/ExtensibleMiddleware folder.

Node Middlewares

Node/Express middlewares are no supported, but currently only on the application level (i.e. they will be run on every request).

For our example, we'll use two existing node middlewares, and one custom node middleware. The first one is morgan, which adds logging to our http server. Install it using

npm install morgan --save

To use it, we create a FFI and export it as morgan

import { default as M } from "morgan";
export const morgan = M("tiny")

Now we can define the foreign import in our Purescript file:

foreign import morgan :: NodeMiddleware ()

The empty row () indicates, that this middleware doesn't add any information to our request.

Next, we'll add helmet which adds some security headers to our response.

npm install helmet --save

Add it to our ffi

import { default as H } from "helmet";
export const helmet = H()

and define the foreign import

foreign import helmet :: NodeMiddleware ()

Finally, let's define a small node middleware that adds something to our request. Add a simple authenticating middleware to our ffi:

export const authenticator = function (req, res, next) {
  if(req.headers["x-token"] == "123") {
    req.user = "John Doe" 
  } else {
    req.user = null
  }
  next();
};

If the middleware receives the http header x-token with value 123, it will add the user, otherwise null.

Our foreign import now looks like this:

type AuthenticatorR = (user :: Nullable String)

foreign import authenticator :: NodeMiddleware (user :: Nullable String)

Our node middleware extends requests with (user :: Nullable String).

We can now compose our middlewares into a single NodeMiddlewareStack:

nodeMiddleware ∷ NodeMiddlewareStack () AuthenticatorR
nodeMiddleware = NodeMiddlewareStack $ usingMiddleware morgan >=> usingMiddleware helmet >=> usingMiddleware authenticator

Let's define a simple route with a handler, that makes use of our node middleware:

data Route = Hello

derive instance Generic Route _

route :: RouteDuplex' Route
route = mkRoute
  { "Hello": noArgs
  }

main :: ServerM
main =
  serveNodeMiddleware { port: 8080 } { route, router: router, nodeMiddleware }
  where
  router { route: Hello, user } = case Nullable.toMaybe user of
    Just u -> ok $ "hello user " <> u
    Nothing -> ok $ "hello anonymous"

Note, that we have to use serveNodeMiddleware instead of serve and pass the nodeMiddleware along the route and router. In the router, we gain access to the nullable user.

You can see a full example of node middlewares in the Examples/NodeMiddleware folder.