packages feed

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

-- | Runtime request validation middleware.
--
-- Validates incoming requests against the API type before they reach
-- handlers: correct method for matched path, path actually matches
-- a known route. Rejects non-matching requests with 400/404 before
-- handler dispatch.
--
-- @
-- svc |> validationLayer @MyAPI
-- @
module Acolyte.Server.Validate
  ( -- * Validation middleware
    validationLayer
    -- * Route table
  , RouteInfo (..)
  , BuildRouteTable (..)
  ) where

import Data.ByteString (ByteString)
import Data.Kind (Type)
import Data.Text (Text)
import qualified Data.Text as T
import Network.HTTP.Types (status404, status405)

import Spire (Middleware, middleware)
import Spire.Service (Service (..))
import Http.Core (Request (..), Response (..))

import Acolyte.Server.Handler (HasEndpointInfo (..), MatchResult (..))


-- | Info about a single route for validation.
data RouteInfo = RouteInfo
  { riMethod  :: !ByteString
  , riPattern :: !Text
  , riMatcher :: [Text] -> Maybe MatchResult
  }


-- | Build a route table from an API type.
class BuildRouteTable (api :: [Type]) where
  routeTable :: [RouteInfo]

instance BuildRouteTable '[] where
  routeTable = []

instance (HasEndpointInfo e, BuildRouteTable rest)
  => BuildRouteTable (e ': rest) where
    routeTable = RouteInfo
      { riMethod  = endpointMethod @e
      , riPattern = endpointPattern @e
      , riMatcher = endpointMatcher @e
      } : routeTable @rest


-- | Validation middleware that rejects requests not matching the API.
--
-- Checks:
-- 1. Path matches at least one endpoint → if not, 404
-- 2. Method matches for the matched path → if not, 405
--
-- This runs BEFORE the router, catching obviously invalid requests
-- early (useful when the router has fallback behavior or when you
-- want a strict "only defined routes" policy).
validationLayer
  :: forall api. BuildRouteTable api
  => Middleware IO (Request ByteString) (Response ByteString)
validationLayer = middleware $ \(Service inner) -> Service $ \req -> do
  let segments = requestPath req
      method   = requestMethod req
      routes   = routeTable @api
      pathMatches = [ ri | ri <- routes, isJust (riMatcher ri segments) ]
  case pathMatches of
    [] -> pure (Response status404 [] "Not Found: no route matches this path")
    matches ->
      if any (\ri -> riMethod ri == method) matches
      then inner req  -- valid: let it through
      else pure (Response status405 [] "Method Not Allowed")


isJust :: Maybe a -> Bool
isJust (Just _) = True
isJust Nothing  = False