acolyte-server-0.1.0.0: src/Acolyte/Server/Extract.hs
-- | Request extraction: turning raw requests into typed handler arguments.
--
-- Two extraction protocols mirror the typeway/axum pattern:
--
-- * 'FromRequestParts' — extracts from method, path, query, headers,
-- extensions (can be called multiple times per request)
-- * 'FromRequest' — extracts from the body (consumed once)
module Acolyte.Server.Extract
( -- * Extraction protocols
FromRequestParts (..)
, FromRequest (..)
-- * Built-in extractors
, PathCapture (..)
, JsonBody (..)
, ValidatedBody (..)
, AppState (..)
, RawBody (..)
, ReqHeader (..)
, Extension (..)
, Optional (..)
, ReqMethod (..)
-- * Query parameter extractors
, QueryParam (..)
, QueryParams (..)
, OptionalParam (..)
-- * Header extractors
, HeaderMap (..)
-- * Body extractors
, BodyBytes (..)
, StringBody (..)
, Form (..)
, FromForm (..)
, RawForm (..)
, Multipart (..)
, FilePart (..)
-- * Form/Multipart parsing (internal, exported for testing)
, parseFormUrlEncoded
, parseMultipart
-- * Request access
, FullRequest (..)
, RawQuery (..)
, OriginalUri (..)
, MatchedPath (..)
, NestedPath (..)
, ConnectInfo (..)
-- * Capture support
, CaptureList (..)
, RawPathParams (..)
, ParseCapture (..)
-- * Server error
, ServerError (..)
, mkError
) where
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI
import Data.Kind (Type)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Read as T
import Data.Typeable (Typeable)
import GHC.TypeLits (Symbol, KnownSymbol, symbolVal)
import Data.Proxy (Proxy (..))
import Network.HTTP.Types (Status, status400, status422, status500, urlDecode)
import qualified Data.Aeson as Aeson
import Http.Core (RequestParts (..), Extensions, lookupExtension, insertExtension)
import Acolyte.Core.Wrapper (Validate (..))
-- | A structured server error with status and message.
data ServerError = ServerError
{ seStatus :: !Status
, seMessage :: !Text
} deriving (Show)
-- | Construct a 'ServerError' from a status code and message.
mkError :: Status -> Text -> ServerError
mkError = ServerError
-- | Extract a value from request data available via 'RequestParts'.
--
-- Originally designed for non-body extractions (path, query, headers,
-- extensions), but body types ('JsonBody', 'RawBody', 'StringBody',
-- 'Form', 'Multipart') also have instances — they read from
-- 'BodyBytes' stored in request extensions by the router. This means
-- 'FromRequestParts' is the universal extraction protocol used by
-- 'ToHandler' to peel handler arguments, regardless of whether the
-- data comes from the URL, headers, or body.
--
-- Can be called multiple times (nothing is consumed).
class FromRequestParts a where
fromRequestParts :: RequestParts -> IO (Either ServerError a)
-- | Extract a value from the request body (legacy protocol).
--
-- This is the original body extraction interface, taking the raw body
-- bytes as a parameter. It is used by the low-level 'HandlerFn' path
-- ('mkHandler1Body', 'mkHandler2PartsBody'). For ergonomic handlers
-- via 'ToHandler', body extraction goes through 'FromRequestParts'
-- instead (via 'BodyBytes' in extensions).
class FromRequest a where
fromRequest :: RequestParts -> ByteString -> IO (Either ServerError a)
-- ===================================================================
-- ParseCapture: text -> typed value parsing
-- ===================================================================
-- | Parse a text capture into a typed value.
class ParseCapture a where
parseCapture :: Text -> Maybe a
instance ParseCapture Int where
parseCapture t = case T.decimal t of
Right (n, rest) | T.null rest -> Just n
_ -> Nothing
instance ParseCapture Text where
parseCapture = Just
instance ParseCapture String where
parseCapture = Just . T.unpack
-- ===================================================================
-- BodyBytes: raw body stored in Extensions by the router
-- ===================================================================
-- | Raw request body bytes, stored in Extensions by the router.
-- Enables body extractors to work through 'FromRequestParts'.
newtype BodyBytes = BodyBytes { unBodyBytes :: ByteString }
deriving (Show, Eq, Typeable)
-- ===================================================================
-- CaptureList: raw captures stored in Extensions by the router
-- ===================================================================
-- | Raw captured path segments stored in Extensions by the router.
newtype CaptureList = CaptureList { unCaptureList :: [Text] }
deriving (Typeable)
-- ===================================================================
-- PathCapture: extract typed captures from the URL path
-- ===================================================================
-- | A typed path capture extracted from the URL.
--
-- For a path like @'[Lit "users", Capture Int]@, use
-- @PathCapture Int@ in your handler to get the parsed value:
--
-- @
-- getUser :: PathCapture Int -> IO (Json User)
-- getUser (PathCapture uid) = ...
-- @
newtype PathCapture a = PathCapture { unPathCapture :: a }
deriving (Show, Eq, Typeable)
instance (Typeable a, ParseCapture a) => FromRequestParts (PathCapture a) where
fromRequestParts parts = do
mCaps <- lookupExtension @CaptureList (rpExtensions parts)
case mCaps of
Just (CaptureList (t : rest)) ->
case parseCapture @a t of
Just val -> do
-- Consume the capture: update CaptureList to remove the head
insertExtension (CaptureList rest) (rpExtensions parts)
pure $ Right (PathCapture val)
Nothing -> pure $ Left (mkError status400 "Invalid path capture")
Just (CaptureList []) ->
pure $ Left (mkError status400 "Missing path capture")
Nothing ->
pure $ Left (mkError status500 "CaptureList not found in extensions (router bug)")
-- ===================================================================
-- JsonBody: parse JSON request body
-- ===================================================================
-- | A JSON-decoded request body.
--
-- Works as both a 'FromRequest' extractor (with the old HandlerFn API)
-- and a 'FromRequestParts' extractor (with ergonomic handlers via
-- 'ToHandler'), since the router stores body bytes in Extensions.
--
-- @
-- createUser :: JsonBody CreateUserReq -> IO (Json User)
-- createUser (JsonBody req) = ...
-- @
newtype JsonBody a = JsonBody { unJsonBody :: a }
deriving (Show, Eq)
instance Aeson.FromJSON a => FromRequest (JsonBody a) where
fromRequest _parts body =
case Aeson.eitherDecodeStrict' body of
Right val -> pure (Right (JsonBody val))
Left err -> pure (Left (mkError status422 (T.pack ("JSON parse error: " ++ err))))
instance Aeson.FromJSON a => FromRequestParts (JsonBody a) where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
pure $ case mBody of
Just (BodyBytes body) ->
case Aeson.eitherDecodeStrict' body of
Right val -> Right (JsonBody val)
Left err -> Left (mkError status422 (T.pack ("JSON parse error: " ++ err)))
Nothing -> Left (mkError status500 "Request body not available (router bug)")
-- ===================================================================
-- ValidatedBody: JSON body with validation
-- ===================================================================
-- | A JSON-deserialized request body that has been validated.
--
-- Uses the 'Validate' type class from core to run validation after
-- deserialization. Returns 422 if validation fails.
--
-- @
-- data CreateUserValidator
-- instance Validate CreateUserValidator CreateUser where
-- validate u
-- | T.null (userName u) = Left "name is required"
-- | otherwise = Right u
--
-- handler :: ValidatedBody CreateUserValidator CreateUser -> IO (Json User)
-- handler (ValidatedBody user) = ...
-- @
newtype ValidatedBody v a = ValidatedBody { unValidatedBody :: a }
deriving (Show, Eq)
instance (Aeson.FromJSON a, Validate v a) => FromRequest (ValidatedBody v a) where
fromRequest _parts body =
case Aeson.eitherDecodeStrict' body of
Left err -> pure (Left (mkError status422 (T.pack ("JSON parse error: " ++ err))))
Right a -> case validate @v a of
Left msg -> pure (Left (mkError status422 (T.pack msg)))
Right a' -> pure (Right (ValidatedBody a'))
instance (Aeson.FromJSON a, Validate v a) => FromRequestParts (ValidatedBody v a) where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
pure $ case mBody of
Just (BodyBytes body) ->
case Aeson.eitherDecodeStrict' body of
Left err -> Left (mkError status422 (T.pack ("JSON parse error: " ++ err)))
Right a -> case validate @v a of
Left msg -> Left (mkError status422 (T.pack msg))
Right a' -> Right (ValidatedBody a')
Nothing -> Left (mkError status500 "Request body not available (router bug)")
-- ===================================================================
-- AppState: shared application state from Extensions
-- ===================================================================
-- | Shared application state injected via 'serveWithState'.
--
-- @
-- handler :: AppState DbPool -> PathCapture Int -> IO (Json User)
-- @
newtype AppState a = AppState { unAppState :: a }
deriving (Show, Eq)
instance Typeable a => FromRequestParts (AppState a) where
fromRequestParts parts = do
mVal <- lookupExtension @(AppState a) (rpExtensions parts)
pure $ case mVal of
Just st -> Right st
Nothing -> Left (mkError status500 "AppState not found in extensions (forgot serveWithState?)")
-- ===================================================================
-- RawBody: raw request body bytes
-- ===================================================================
-- | The raw request body as a ByteString.
--
-- Works as both a 'FromRequest' and 'FromRequestParts' extractor.
--
-- @
-- handler :: RawBody -> IO (Response ByteString)
-- handler (RawBody bytes) = ...
-- @
newtype RawBody = RawBody { unRawBody :: ByteString }
deriving (Show, Eq)
instance FromRequest RawBody where
fromRequest _parts body = pure (Right (RawBody body))
instance FromRequestParts RawBody where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
pure $ case mBody of
Just (BodyBytes body) -> Right (RawBody body)
Nothing -> Left (mkError status500 "Request body not available (router bug)")
-- ===================================================================
-- ReqHeader: extract a specific header by type-level name
-- ===================================================================
-- | Extract a specific request header by its type-level name.
--
-- @
-- handler :: ReqHeader "Authorization" -> IO (Json User)
-- @
newtype ReqHeader (name :: Symbol) = ReqHeader { unReqHeader :: ByteString }
deriving (Show, Eq)
instance KnownSymbol name => FromRequestParts (ReqHeader name) where
fromRequestParts parts = do
let headerName = CI.mk (TE.encodeUtf8 (T.pack (symbolVal (Proxy @name))))
pure $ case lookup headerName (rpHeaders parts) of
Just val -> Right (ReqHeader val)
Nothing -> Left (mkError status400
(T.pack ("Missing required header: " ++ symbolVal (Proxy @name))))
-- ===================================================================
-- QueryParam: extract a required query parameter
-- ===================================================================
-- | Extract a required, typed query parameter.
--
-- @
-- handler :: QueryParam "page" Int -> IO (Json [Item])
-- handler (QueryParam page) = ...
-- @
--
-- Returns 400 if the parameter is missing or unparseable.
newtype QueryParam (name :: Symbol) a = QueryParam { unQueryParam :: a }
deriving (Show, Eq)
instance (KnownSymbol name, ParseCapture a) => FromRequestParts (QueryParam name a) where
fromRequestParts parts = do
let name = TE.encodeUtf8 (T.pack (symbolVal (Proxy @name)))
pure $ case lookup name (rpQuery parts) of
Just (Just val) ->
case parseCapture @a (TE.decodeUtf8Lenient val) of
Just v -> Right (QueryParam v)
Nothing -> Left (mkError status400
("Invalid query parameter: " <> T.pack (symbolVal (Proxy @name))))
_ -> Left (mkError status400
("Missing required query parameter: " <> T.pack (symbolVal (Proxy @name))))
-- ===================================================================
-- QueryParams: extract all values for a repeated query parameter
-- ===================================================================
-- | Extract all values for a repeated query parameter.
--
-- @
-- -- /search?tag=haskell&tag=web
-- handler :: QueryParams "tag" Text -> IO (Json [Article])
-- handler (QueryParams tags) = ...
-- @
--
-- Returns an empty list if the parameter is not present.
newtype QueryParams (name :: Symbol) a = QueryParams { unQueryParams :: [a] }
deriving (Show, Eq)
instance (KnownSymbol name, ParseCapture a) => FromRequestParts (QueryParams name a) where
fromRequestParts parts = do
let name = TE.encodeUtf8 (T.pack (symbolVal (Proxy @name)))
vals = [ v | (k, Just raw) <- rpQuery parts
, k == name
, Just v <- [parseCapture @a (TE.decodeUtf8Lenient raw)]
]
pure (Right (QueryParams vals))
-- ===================================================================
-- OptionalParam: extract an optional query parameter
-- ===================================================================
-- | Extract an optional, typed query parameter.
--
-- @
-- handler :: OptionalParam "limit" Int -> IO (Json [Item])
-- handler (OptionalParam mLimit) = do
-- let limit = fromMaybe 20 mLimit
-- ...
-- @
newtype OptionalParam (name :: Symbol) a = OptionalParam { unOptionalParam :: Maybe a }
deriving (Show, Eq)
instance (KnownSymbol name, ParseCapture a) => FromRequestParts (OptionalParam name a) where
fromRequestParts parts = do
let name = TE.encodeUtf8 (T.pack (symbolVal (Proxy @name)))
pure $ Right $ OptionalParam $ case lookup name (rpQuery parts) of
Just (Just val) -> parseCapture @a (TE.decodeUtf8Lenient val)
_ -> Nothing
-- ===================================================================
-- Extension: extract typed data from request extensions
-- ===================================================================
-- | Extract typed data from request extensions.
-- Middleware stores data via 'insertExtension'; handlers retrieve it here.
--
-- @
-- handler :: Extension RequestId -> IO Response
-- @
newtype Extension a = Extension { unExtension :: a }
deriving (Show, Eq)
instance Typeable a => FromRequestParts (Extension a) where
fromRequestParts parts = do
mVal <- lookupExtension @a (rpExtensions parts)
pure $ case mVal of
Just val -> Right (Extension val)
Nothing -> Left (mkError status500 "Extension not found in request")
-- ===================================================================
-- Optional: make any FromRequestParts extractor optional
-- ===================================================================
-- | Wraps any 'FromRequestParts' extractor to make it optional.
-- Returns 'Nothing' instead of failing if extraction fails.
--
-- @
-- handler :: Optional (ReqHeader "Authorization") -> IO Response
-- @
newtype Optional a = Optional { unOptional :: Maybe a }
deriving (Show, Eq)
instance FromRequestParts a => FromRequestParts (Optional a) where
fromRequestParts parts = do
result <- fromRequestParts @a parts
pure $ Right $ Optional $ case result of
Right val -> Just val
Left _ -> Nothing
-- ===================================================================
-- ReqMethod: extract the HTTP method
-- ===================================================================
-- | Extract the HTTP method from the request.
newtype ReqMethod = ReqMethod { unReqMethod :: ByteString }
deriving (Show, Eq)
instance FromRequestParts ReqMethod where
fromRequestParts parts = pure (Right (ReqMethod (rpMethod parts)))
-- ===================================================================
-- HeaderMap: all request headers
-- ===================================================================
-- | All request headers as an association list.
--
-- @
-- handler :: HeaderMap -> IO (Json Value)
-- handler (HeaderMap hdrs) = ...
-- @
newtype HeaderMap = HeaderMap { unHeaderMap :: [(CI.CI ByteString, ByteString)] }
deriving (Show, Eq)
instance FromRequestParts HeaderMap where
fromRequestParts parts = pure (Right (HeaderMap (rpHeaders parts)))
-- ===================================================================
-- FullRequest: access the complete RequestParts
-- ===================================================================
-- | Access the full 'RequestParts' — method, path, query, headers,
-- extensions. Use when no specific extractor fits.
--
-- @
-- handler :: FullRequest -> IO (Response ByteString)
-- handler (FullRequest parts) = ...
-- @
newtype FullRequest = FullRequest { unFullRequest :: RequestParts }
instance FromRequestParts FullRequest where
fromRequestParts parts = pure (Right (FullRequest parts))
-- ===================================================================
-- RawQuery: unparsed query string
-- ===================================================================
-- | The raw query string from the request path (everything after @?@).
--
-- @
-- handler :: RawQuery -> IO Text
-- handler (RawQuery q) = ...
-- @
newtype RawQuery = RawQuery { unRawQuery :: ByteString }
deriving (Show, Eq)
instance FromRequestParts RawQuery where
fromRequestParts parts =
let raw = rpPathRaw parts
q = BS.drop 1 (snd (BS.break (== 0x3F) raw)) -- 0x3F = '?'
in pure (Right (RawQuery q))
-- ===================================================================
-- RawPathParams: untyped path captures as text
-- ===================================================================
-- | The raw captured path segments as a list of 'Text', before
-- any type-level parsing. Useful for logging or debugging.
--
-- @
-- handler :: RawPathParams -> IO Text
-- handler (RawPathParams segs) = ...
-- @
newtype RawPathParams = RawPathParams { unRawPathParams :: [Text] }
deriving (Show, Eq)
instance FromRequestParts RawPathParams where
fromRequestParts parts = do
mCaps <- lookupExtension @CaptureList (rpExtensions parts)
pure $ Right $ RawPathParams $ case mCaps of
Just (CaptureList caps) -> caps
Nothing -> []
-- ===================================================================
-- StringBody: request body as Text (UTF-8)
-- ===================================================================
-- | The request body decoded as UTF-8 'Text'.
--
-- @
-- handler :: StringBody -> IO Text
-- handler (StringBody txt) = pure ("Echo: " <> txt)
-- @
newtype StringBody = StringBody { unStringBody :: Text }
deriving (Show, Eq)
instance FromRequest StringBody where
fromRequest _parts body = pure (Right (StringBody (TE.decodeUtf8Lenient body)))
instance FromRequestParts StringBody where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
pure $ case mBody of
Just (BodyBytes body) -> Right (StringBody (TE.decodeUtf8Lenient body))
Nothing -> Left (mkError status500 "Request body not available (router bug)")
-- ===================================================================
-- OriginalUri: the original request URI
-- ===================================================================
-- | The original request URI before any routing transformations.
-- Stored in Extensions by the router.
--
-- @
-- handler :: OriginalUri -> IO Text
-- handler (OriginalUri uri) = ...
-- @
newtype OriginalUri = OriginalUri { unOriginalUri :: ByteString }
deriving (Show, Eq, Typeable)
instance FromRequestParts OriginalUri where
fromRequestParts parts =
-- If stored by the router, use that; otherwise fall back to rpPathRaw
do mUri <- lookupExtension @OriginalUri (rpExtensions parts)
pure $ Right $ case mUri of
Just uri -> uri
Nothing -> OriginalUri (rpPathRaw parts)
-- ===================================================================
-- MatchedPath: the route pattern that matched
-- ===================================================================
-- | The route pattern that matched this request (e.g. @\/users\/{capture}@).
-- Stored in Extensions by the router.
--
-- @
-- handler :: MatchedPath -> IO Text
-- handler (MatchedPath pat) = ... -- e.g. "/users/{capture}"
-- @
newtype MatchedPath = MatchedPath { unMatchedPath :: Text }
deriving (Show, Eq, Typeable)
instance FromRequestParts MatchedPath where
fromRequestParts parts = do
mPath <- lookupExtension @MatchedPath (rpExtensions parts)
pure $ case mPath of
Just mp -> Right mp
Nothing -> Left (mkError status500 "MatchedPath not found in extensions (router bug)")
-- ===================================================================
-- NestedPath: nesting context for sub-routers
-- ===================================================================
-- | The path prefix consumed by parent routers when using sub-API
-- composition. Useful for building relative URLs.
--
-- @
-- handler :: NestedPath -> IO Text
-- handler (NestedPath prefix) = ...
-- @
newtype NestedPath = NestedPath { unNestedPath :: [Text] }
deriving (Show, Eq, Typeable)
instance FromRequestParts NestedPath where
fromRequestParts parts = do
mNested <- lookupExtension @NestedPath (rpExtensions parts)
pure $ Right $ case mNested of
Just np -> np
Nothing -> NestedPath []
-- ===================================================================
-- ConnectInfo: client connection information
-- ===================================================================
-- | Client connection metadata (IP address, port).
-- Stored in Extensions by the backend adapter (spire-server, spire-wai).
--
-- @
-- handler :: ConnectInfo -> IO Text
-- handler (ConnectInfo host port) = ...
-- @
data ConnectInfo = ConnectInfo
{ ciHost :: !Text
, ciPort :: !Int
} deriving (Show, Eq, Typeable)
instance FromRequestParts ConnectInfo where
fromRequestParts parts = do
mInfo <- lookupExtension @ConnectInfo (rpExtensions parts)
pure $ case mInfo of
Just ci -> Right ci
Nothing -> Left (mkError status500
"ConnectInfo not available (backend adapter must inject it)")
-- ===================================================================
-- Form: url-encoded form body
-- ===================================================================
-- | A form-decoded request body (@application\/x-www-form-urlencoded@).
--
-- @
-- data LoginForm = LoginForm { lfUser :: Text, lfPass :: Text }
--
-- handler :: Form LoginForm -> IO (Json Session)
-- handler (Form form) = ...
-- @
--
-- The inner type must have a 'FromForm' instance (provided by the user)
-- which converts @[(ByteString, ByteString)]@ to the desired type.
newtype Form a = Form { unForm :: a }
deriving (Show, Eq)
-- | Convert parsed form key-value pairs into a typed value.
class FromForm a where
fromForm :: [(ByteString, ByteString)] -> Either Text a
instance FromForm a => FromRequest (Form a) where
fromRequest _parts body =
let pairs = parseFormUrlEncoded body
in pure $ case fromForm pairs of
Right val -> Right (Form val)
Left err -> Left (mkError status422 ("Form parse error: " <> err))
instance FromForm a => FromRequestParts (Form a) where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
pure $ case mBody of
Just (BodyBytes body) ->
case fromForm (parseFormUrlEncoded body) of
Right val -> Right (Form val)
Left err -> Left (mkError status422 ("Form parse error: " <> err))
Nothing -> Left (mkError status500 "Request body not available (router bug)")
-- | Parse @application\/x-www-form-urlencoded@ bytes into key-value pairs.
--
-- Uses 'Network.HTTP.Types.urlDecode' for proper percent-decoding
-- (handles @%XX@ encoding and @+@ as space).
parseFormUrlEncoded :: ByteString -> [(ByteString, ByteString)]
parseFormUrlEncoded bs
| BS.null bs = []
| otherwise = map parsePair (BS.split 0x26 bs) -- 0x26 = '&'
where
parsePair p =
let (k, rest) = BS.break (== 0x3D) p -- 0x3D = '='
in (urlDecode True k, urlDecode True (BS.drop 1 rest))
-- ===================================================================
-- RawForm: unparsed form key-value pairs
-- ===================================================================
-- | Raw form data as key-value pairs without typed parsing.
-- For @application\/x-www-form-urlencoded@ bodies.
--
-- @
-- handler :: RawForm -> IO Text
-- handler (RawForm pairs) = ...
-- @
newtype RawForm = RawForm { unRawForm :: [(ByteString, ByteString)] }
deriving (Show, Eq)
instance FromRequest RawForm where
fromRequest _parts body = pure (Right (RawForm (parseFormUrlEncoded body)))
instance FromRequestParts RawForm where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
pure $ case mBody of
Just (BodyBytes body) -> Right (RawForm (parseFormUrlEncoded body))
Nothing -> Left (mkError status500 "Request body not available (router bug)")
-- ===================================================================
-- Multipart: file uploads (multipart/form-data)
-- ===================================================================
-- | A parsed multipart form body, containing fields and file uploads.
--
-- @
-- handler :: Multipart -> IO (Json UploadResult)
-- handler (Multipart parts) = do
-- let files = [p | p <- parts, fpFileName p /= Nothing]
-- ...
-- @
--
-- Each part contains the field name, optional filename, content type,
-- and body bytes.
newtype Multipart = Multipart { unMultipart :: [FilePart] }
deriving (Show, Eq)
-- | A single part of a multipart form submission.
data FilePart = FilePart
{ fpFieldName :: !Text
, fpFileName :: !(Maybe Text)
, fpContentType :: !ByteString
, fpBody :: !ByteString
} deriving (Show, Eq)
instance FromRequest Multipart where
fromRequest parts body = do
let ct = lookup (CI.mk "content-type") (rpHeaders parts)
pure $ case ct >>= extractBoundary of
Just boundary -> Right (Multipart (parseMultipart boundary body))
Nothing -> Left (mkError status400
"Missing or invalid Content-Type for multipart/form-data")
instance FromRequestParts Multipart where
fromRequestParts parts = do
mBody <- lookupExtension @BodyBytes (rpExtensions parts)
let ct = lookup (CI.mk "content-type") (rpHeaders parts)
pure $ case (mBody, ct >>= extractBoundary) of
(Just (BodyBytes body), Just boundary) ->
Right (Multipart (parseMultipart boundary body))
(Nothing, _) ->
Left (mkError status500 "Request body not available (router bug)")
(_, Nothing) ->
Left (mkError status400
"Missing or invalid Content-Type for multipart/form-data")
-- | Extract the boundary string from a multipart Content-Type header.
extractBoundary :: ByteString -> Maybe ByteString
extractBoundary ct
| "multipart/form-data" `BS.isPrefixOf` ct =
let parts = BS.split 0x3B ct -- 0x3B = ';'
in case filter (hasBoundary . BS.dropWhile (== 0x20)) parts of
(p : _) -> Just (BS.drop 1 (snd (BS.break (== 0x3D) (BS.dropWhile (== 0x20) p))))
[] -> Nothing
| otherwise = Nothing
where
hasBoundary s = "boundary=" `BS.isPrefixOf` s || "boundary=" `BS.isPrefixOf` BS.map toLowerW8 s
toLowerW8 w
| w >= 0x41 && w <= 0x5A = w + 0x20
| otherwise = w
-- | Maximum number of parts allowed in a multipart body.
-- Prevents excessive memory/CPU usage from adversarial inputs with
-- thousands of parts.
maxMultipartParts :: Int
maxMultipartParts = 1000
-- | Parse a multipart body given the boundary string.
-- This is a simplified parser that handles the common case.
-- Output is capped at 'maxMultipartParts' parts.
parseMultipart :: ByteString -> ByteString -> [FilePart]
parseMultipart boundary body =
let delim = "--" <> boundary
chunks = splitOnBoundary delim body
in take maxMultipartParts (concatMap parsePart chunks)
-- | Split the body on boundary delimiters.
splitOnBoundary :: ByteString -> ByteString -> [ByteString]
splitOnBoundary delim body = go body
where
go bs
| BS.null bs = []
| otherwise = case BS.breakSubstring delim bs of
(_, rest) | BS.null rest -> []
(_, rest) ->
let afterDelim = BS.drop (BS.length delim) rest
-- Skip CRLF after boundary
afterCrlf = if "\r\n" `BS.isPrefixOf` afterDelim
then BS.drop 2 afterDelim
else if "\n" `BS.isPrefixOf` afterDelim
then BS.drop 1 afterDelim
else afterDelim
in if "--" `BS.isPrefixOf` afterDelim
then [] -- final boundary
else case BS.breakSubstring delim afterCrlf of
(chunk, _) ->
let trimmed = if "\r\n" `BS.isSuffixOf` chunk
then BS.take (BS.length chunk - 2) chunk
else chunk
in trimmed : go (BS.drop (BS.length chunk) afterCrlf)
-- | Parse a single multipart part (headers + body).
parsePart :: ByteString -> [FilePart]
parsePart chunk
| BS.null chunk = []
| otherwise =
let (headerSection, bodySection) = splitHeaders chunk
headers = parsePartHeaders headerSection
fieldName = lookupPartHeader "name" headers
fileName = lookupPartHeader "filename" headers
contentType = maybe "application/octet-stream" id
(lookup "content-type" headers >>= id)
in case fieldName of
Just name -> [FilePart
{ fpFieldName = TE.decodeUtf8Lenient name
, fpFileName = TE.decodeUtf8Lenient <$> fileName
, fpContentType = contentType
, fpBody = bodySection
}]
Nothing -> []
-- | Split part into headers and body at the blank line.
splitHeaders :: ByteString -> (ByteString, ByteString)
splitHeaders bs = case BS.breakSubstring "\r\n\r\n" bs of
(h, rest) | not (BS.null rest) -> (h, BS.drop 4 rest)
_ -> case BS.breakSubstring "\n\n" bs of
(h, rest) | not (BS.null rest) -> (h, BS.drop 2 rest)
_ -> (bs, "")
-- | Parse part headers into key-value pairs.
parsePartHeaders :: ByteString -> [(ByteString, Maybe ByteString)]
parsePartHeaders bs =
let ls = filter (not . BS.null) (BS.split 0x0A bs) -- split on LF
in concatMap parseOneHeader ls
where
parseOneHeader line =
let cleaned = if "\r" `BS.isSuffixOf` line then BS.init line else line
(name, rest) = BS.break (== 0x3A) cleaned -- ':'
val = BS.dropWhile (== 0x20) (BS.drop 1 rest)
in if "content-disposition" `BS.isPrefixOf` BS.map toLowerW8 name
then [("content-disposition", Just val)] ++ parseDisposition val
else if "content-type" `BS.isPrefixOf` BS.map toLowerW8 name
then [("content-type", Just val)]
else [(BS.map toLowerW8 name, Just val)]
toLowerW8 w
| w >= 0x41 && w <= 0x5A = w + 0x20
| otherwise = w
-- | Parse Content-Disposition parameters (name, filename).
parseDisposition :: ByteString -> [(ByteString, Maybe ByteString)]
parseDisposition val =
let parts = BS.split 0x3B val -- ';'
in concatMap parseParam (drop 1 parts)
where
parseParam p =
let trimmed = BS.dropWhile (== 0x20) p
(key, rest) = BS.break (== 0x3D) trimmed -- '='
rawVal = BS.drop 1 rest
unquoted = if BS.length rawVal >= 2
&& BS.head rawVal == 0x22 -- '"'
&& BS.last rawVal == 0x22
then BS.init (BS.tail rawVal)
else rawVal
in [(BS.map toLowerW8 key, Just unquoted)]
toLowerW8 w
| w >= 0x41 && w <= 0x5A = w + 0x20
| otherwise = w
-- | Lookup a parameter from parsed disposition headers.
lookupPartHeader :: ByteString -> [(ByteString, Maybe ByteString)] -> Maybe ByteString
lookupPartHeader key headers = do
(_, mVal) <- find (\(k, _) -> k == key) headers
mVal
where
find _ [] = Nothing
find p (x:xs) = if p x then Just x else find p xs