packages feed

haskoin-store-1.9.0: src/Haskoin/Store/Web/Router.hs

-- |
-- Module      : Haskoin.Store.Web.Router
-- Copyright   : (c) Haskoin Developers
-- License     : MIT
-- Maintainer  : jprupp@protonmail.ch
-- Stability   : experimental
-- Portability : POSIX
--
-- Routing of requests to handlers, directly over WAI.
--
-- A 'Route' is a method, a path pattern such as @\/block\/:block@, the names
-- of the parameters it accepts in the query string or a form, and a handler:
-- a function from the request to a response in 'IO'. 'router' turns a list of
-- them into a WAI 'Application' that answers with the first route whose
-- method and path match. A handler that finds the request is not for it after
-- all, such as one whose path parameter does not parse as the kind of value
-- it serves, calls 'next', and the request goes to the next route that
-- matches.
--
-- = Parameters are declared
--
-- A request with a parameter that its route does not accept is refused
-- ('Unknown'), rather than answered as if the parameter were not there. A
-- misspelt parameter, such as @limt=10@, or one that an endpoint does not
-- support, would otherwise be ignored, and the client would get an answer to
-- a question it did not ask, with nothing to tell it so. The check runs
-- before the handler, so a refused request has no effect, which matters for
-- a handler that does something, such as broadcasting a transaction.
--
-- The declaration and the handler cannot drift apart: reading a parameter
-- that the route does not declare ('param') throws 'Undeclared', an error of
-- the program that the tests of each endpoint find, rather than a parameter
-- that is accepted and never read, or read and never accepted.
--
-- = Why not a web framework
--
-- The server needs routing, parameters and responses; a framework like
-- Scotty adds them in a monad transformer over 'IO' that every handler has to
-- run in, with its own exceptions for control flow and its own way to return
-- a response. Here a handler is a plain function: it gets everything about
-- the request in a 'Req', returns a WAI 'Response', and stops early by
-- throwing, which 'router' and the server catch. The whole router is this
-- module.
module Haskoin.Store.Web.Router
  ( -- * Routes
    Route (..),
    Miss (..),
    Undeclared (..),
    route,
    get,
    post,
    router,
    routerScoped,

    -- * Requests
    Req (..),
    Next (..),
    next,
    capture,
    captureWith,
    param,
    header,
  )
where

import Control.Applicative ((<|>))
import Control.Exception (Exception, catch, throwIO)
import Data.ByteString.Lazy qualified as L
import Data.CaseInsensitive qualified as CI
import Data.List (nub)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8)
import Network.HTTP.Types (Method, methodGet, methodPost, parseQueryText)
import Network.Wai

-- | A request being answered.
data Req = Req
  { -- | the request of WAI
    wai :: !Request,
    -- | the parameters captured from the path, by name
    captures :: ![(Text, Text)],
    -- | the parameters of the query string, then those of a form in the
    -- body; the first one of a name is the one that counts
    params :: ![(Text, Text)],
    -- | the body, read whole before the handler runs
    body :: !L.ByteString,
    -- | the names of the parameters that the route accepts
    accepted :: ![Text]
  }

-- | Where a request should go.
data Route = Route
  { method :: !Method,
    -- | the segments of the path: literals, and captures by name
    pattern :: ![Either Text Text],
    -- | the names of the parameters it accepts in the query string or a form
    accepts :: ![Text],
    handler :: Req -> IO Response
  }

-- | A route for a method, a path such as @\/block\/:block@, where a segment
-- that starts with a colon captures what is there under its name, and the
-- names of the parameters it accepts.
route :: Method -> Text -> [Text] -> (Req -> IO Response) -> Route
route m path = Route m [segment t | t <- T.splitOn "/" path, not (T.null t)]
  where
    segment t = maybe (Left t) Right (T.stripPrefix ":" t)

-- | A route for @GET@.
get :: Text -> [Text] -> (Req -> IO Response) -> Route
get = route methodGet

-- | A route for @POST@.
post :: Text -> [Text] -> (Req -> IO Response) -> Route
post = route methodPost

-- | Why no route answered a request.
data Miss
  = -- | no route has its method and path
    NoRoute
  | -- | the routes with its method and path do not accept these parameters
    Unknown ![Text]
  deriving stock (Show)

-- | What 'param' throws for a parameter that the route does not declare: a
-- mistake in the program, not in the request.
newtype Undeclared = Undeclared Text
  deriving stock (Show)

instance Exception Undeclared

-- | What a handler throws to pass the request to the next route that
-- matches.
data Next = Next
  deriving stock (Show)

instance Exception Next

-- | Pass the request to the next route that matches.
next :: IO a
next = throwIO Next

-- | An application that answers with the first route whose method and path
-- match the request, that accepts all its parameters, and whose handler does
-- not pass it on; or with the fallback, told why none did. The body of the
-- request is read whole first, and parsed as a form if it is one; handlers
-- that stream a response still stream it.
router :: [Route] -> (Req -> Miss -> IO Response) -> Application
router routes = routerScoped routes ($ routes)

-- | 'router' for handlers that need something that lasts as long as one
-- request does, such as a view of a database that must be the same for
-- everything the request reads and must be let go when it is answered.
--
-- The routes are given twice. The first list is used for its methods, paths
-- and parameters only, to find the route of a request. The second argument
-- runs an action with the same routes, in the same order, made for the one
-- request: the handler that answers is taken from those. The response is
-- sent inside that action, so that a handler that streams its response can
-- go on reading while it is sent.
--
-- The alternative, to make the routes once and have each handler take what
-- it needs for itself, would put that in every handler; and making all the
-- routes again for each request, to match against, would cost more than
-- many requests do.
routerScoped ::
  [Route] ->
  (([Route] -> IO (Maybe ResponseReceived)) -> IO (Maybe ResponseReceived)) ->
  (Req -> Miss -> IO Response) ->
  Application
routerScoped routes scoped fallback request respond = do
  body <- strictRequestBody request
  let form
        | lookup "Content-Type" (requestHeaders request)
            == Just "application/x-www-form-urlencoded" =
            parseQueryText (L.toStrict body)
        | otherwise = []
      params = [(k, maybe "" id v) | (k, v) <- queryToText (queryString request) <> form]
      req cs as = Req {wai = request, captures = cs, params = params, body = body, accepted = as}
      path = filter (not . T.null) (pathInfo request)
      -- the parameters that the first route matching the path refused, if any
      go unknown [] = fallback (req [] []) (maybe NoRoute Unknown unknown) >>= respond
      go unknown ((i, r) : rs)
        | r.method == requestMethod request,
          Just cs <- match r.pattern path =
            case nub [k | (k, _) <- params, k `notElem` r.accepts] of
              [] -> scoped (answer i (req cs r.accepts)) >>= maybe (go unknown rs) return
              ks -> go (unknown <|> Just ks) rs
        | otherwise = go unknown rs
  go Nothing (zip [0 :: Int ..] routes)
  where
    -- the response of the handler of a route, sent, or nothing if the
    -- handler passed the request on
    answer i r scopedRoutes =
      ((Just <$> (scopedRoutes !! i).handler r) `catch` \Next -> return Nothing) >>= mapM respond
    queryToText q = [(decodeUtf8Lenient k, decodeUtf8Lenient <$> v) | (k, v) <- q]

-- | The captures of a path, if it matches a pattern.
match :: [Either Text Text] -> [Text] -> Maybe [(Text, Text)]
match [] [] = Just []
match (Left l : ps) (t : ts) | l == t = match ps ts
match (Right n : ps) (t : ts) = ((n, t) :) <$> match ps ts
match _ _ = Nothing

-- | A parameter captured from the path. Passes the request on if there is
-- none of that name, which only a wrong pattern would cause.
capture :: Req -> Text -> IO Text
capture r n = maybe next return (lookup n r.captures)

-- | A parameter captured from the path, read with a parser. Passes the
-- request on if it does not parse: the path may be meant for another route,
-- such as an xpub where an address is expected.
captureWith :: Req -> (Text -> Maybe a) -> Text -> IO a
captureWith r f n = capture r n >>= maybe next return . f

-- | A parameter of the query string or of a form, if there is one. Throws
-- 'Undeclared' if the route does not accept a parameter of that name.
--
-- It is an action rather than a pure function so that the error is thrown
-- when the parameter is read, inside the handler, where the server answers
-- it, and not whenever the value happens to be evaluated, which may be while
-- the response is being sent.
param :: Req -> Text -> IO (Maybe Text)
param r n
  | n `elem` r.accepted = return (lookup n r.params)
  | otherwise = throwIO (Undeclared n)

-- | A header of the request, if there is one.
header :: Req -> Text -> Maybe Text
header r n = decodeUtf8Lenient <$> lookup (CI.mk (encodeUtf8 n)) (requestHeaders r.wai)