packages feed

acolyte-server-0.1.0.0: src/Acolyte/Server/Router.hs

-- | Router and Server: dispatch requests to handlers, produce spire Service.
module Acolyte.Server.Router
  ( -- * Router
    Router
  , emptyRouter
  , addRoute
  , dispatch
    -- * Server construction
  , serve
  , serveWithState
    -- * Capture parsing
  , ParseCapture (..)
  , CaptureList (..)
  , injectCaptures
  ) where

import Data.ByteString (ByteString)
import Data.List (insertBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Ord (comparing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Typeable (Typeable)
import Network.HTTP.Types (status404, status405)

import Spire.Service (Service (..))
import Http.Core
  ( Request (..), Response (..)
  , RequestParts (..), splitRequest
  , Extensions, insertExtension
  )
import Acolyte.Server.Handler
import Acolyte.Server.Extract
  ( AppState (..), PathCapture (..), BodyBytes (..)
  , CaptureList (..), ParseCapture (..)
  , MatchedPath (..), OriginalUri (..)
  )
import Acolyte.Server.Response (IntoResponse (..))


-- | A collection of routes, indexed by first path segment for fast dispatch.
--
-- Routes are split into two groups:
-- * 'riBySegment' — keyed by first literal path segment (O(log n) lookup)
-- * 'riWildcard' — routes whose first segment is a capture (fallback scan)
data Router = Router
  { riBySegment :: !(Map Text [BoundHandler])
  , riWildcard  :: ![BoundHandler]
  }

-- | An empty router with no routes registered.
emptyRouter :: Router
emptyRouter = Router Map.empty []

-- | Whether a pattern segment is a literal or a capture placeholder.
--
-- The 'Ord' instance puts 'Literal' before 'Capture', so when patterns
-- are compared segment-by-segment a literal wins over a capture at the
-- same position. This is what makes literal paths match before
-- conflicting capture paths.
data Specificity = Literal | Capture
  deriving (Eq, Ord, Show)

-- | Add a bound handler as a new route to the router.
--
-- Within each prefix bucket the handler list is kept sorted by
-- specificity. Routes with more literal segments (and literals at
-- earlier positions) come first, so e.g. @/api/articles/feed@ is
-- always tried before @/api/articles/{slug}@ regardless of
-- declaration order.
addRoute :: BoundHandler -> Router -> Router
addRoute bh (Router bySegs wild) =
  case firstLiteral (bhPattern bh) of
    Just seg -> Router (Map.alter insertOrCreate seg bySegs) wild
    Nothing  -> Router bySegs (insertBySpec bh wild)
  where
    insertOrCreate Nothing   = Just [bh]
    insertOrCreate (Just bs) = Just (insertBySpec bh bs)

-- | Insert into a bucket sorted by pattern specificity (most specific
-- first). For routes of equal specificity the newly inserted handler
-- goes before existing ones, preserving the historical
-- "newer-overrides-older" behavior within a tie.
insertBySpec :: BoundHandler -> [BoundHandler] -> [BoundHandler]
insertBySpec = insertBy (comparing (patternSpecificity . bhPattern))

-- | Compute a pattern's specificity as a list of segment kinds.
--
-- Compared lexicographically with 'Literal' < 'Capture', so a more
-- specific pattern (more literals at earlier positions) sorts before
-- a less specific one.
patternSpecificity :: Text -> [Specificity]
patternSpecificity pat =
  map segKind $ filter (/= "") $ T.splitOn "/" pat
  where
    segKind seg
      | T.isPrefixOf "{" seg = Capture
      | otherwise            = Literal

-- | Extract the first literal segment from a pattern like "/users/{capture}".
firstLiteral :: Text -> Maybe Text
firstLiteral pat =
  case filter (/= "") $ T.splitOn "/" pat of
    (seg : _) | not (T.isPrefixOf "{" seg) -> Just seg
    _ -> Nothing


-- | Inject captured text segments into extensions.
injectCaptures :: [Text] -> Extensions -> IO ()
injectCaptures caps exts = insertExtension (CaptureList caps) exts


-- | Dispatch a request through the router.
--
-- First looks up the first path segment in the index (O(log n)).
-- Falls back to wildcard routes if no indexed match.
dispatch :: Router -> Request ByteString -> IO (Response ByteString)
dispatch router req = do
  let segments = requestPath req
      method   = requestMethod req
      (parts, body) = splitRequest req
      -- Look up candidates by first segment
      candidates = case segments of
        (seg : _) -> Map.findWithDefault [] seg (riBySegment router)
                     ++ riWildcard router
        []        -> riWildcard router
  go candidates segments method parts body False
  where
    go [] _segs _method _parts _body methodMatched
      | methodMatched = pure (Response status405 [] "Method Not Allowed")
      | otherwise     = pure (Response status404 [] "Not Found")

    go (bh : rest) segs method parts body methodMatched =
      case bhMatchFn bh segs of
        Just (MatchResult caps) ->
          if bhMethod bh == method
          then do
            injectCaptures caps (rpExtensions parts)
            insertExtension (BodyBytes body) (rpExtensions parts)
            insertExtension (MatchedPath (bhPattern bh)) (rpExtensions parts)
            insertExtension (OriginalUri (rpPathRaw parts)) (rpExtensions parts)
            bhHandler bh parts body
          else
            go rest segs method parts body True
        Nothing ->
          go rest segs method parts body methodMatched


-- | Build a spire Service from a router.
serve :: Router -> Service IO (Request ByteString) (Response ByteString)
serve router = Service (dispatch router)


-- | Build a service with shared state injected into every request.
serveWithState
  :: Typeable s
  => s -> Router -> Service IO (Request ByteString) (Response ByteString)
serveWithState state router = Service $ \req -> do
  insertExtension (AppState state) (requestExtensions req)
  dispatch router req