acolyte-server-0.1.0.0: src/Acolyte/Server/Negotiate.hs
-- | Server-side content negotiation for @Negotiate@ types.
--
-- Parses the @Accept@ header, matches against the format list,
-- and serializes the response using the best matching format.
module Acolyte.Server.Negotiate
( -- * Content format serialization
FormatEncoder (..)
-- * Negotiation
, negotiate
, parseAccept
, matchFormat
-- * Negotiated response wrapper
, NegotiatedResponse (..)
) where
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Char8 as BS8
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.List (sortBy)
import Data.Ord (Down (..))
import Data.Char (isSpace, isDigit)
import Network.HTTP.Types (status200, status406)
import qualified Data.Aeson as Aeson
import Http.Core (Response (..))
import Acolyte.Core.Negotiate
( ContentFormat (..)
, JsonFormat
, XmlFormat
, TextFormat
, HtmlFormat
)
-- | Encode a value in a specific content format.
--
-- Each @(format, value-type)@ pair needs an instance.
class ContentFormat fmt => FormatEncoder fmt a where
formatEncode :: a -> ByteString
-- | JSON encoding via aeson.
instance Aeson.ToJSON a => FormatEncoder JsonFormat a where
formatEncode = LBS.toStrict . Aeson.encode
-- | Plain text encoding for 'Text'.
instance FormatEncoder TextFormat Text where
formatEncode = TE.encodeUtf8
-- | Plain text encoding for 'String'.
instance FormatEncoder TextFormat String where
formatEncode = TE.encodeUtf8 . T.pack
-- | Simple XML wrapping. Not a full XML serializer — wraps the value
-- in a @\<value\>@ tag using its 'Show' instance.
instance Show a => FormatEncoder XmlFormat a where
formatEncode a =
TE.encodeUtf8 $ "<?xml version=\"1.0\"?><value>" <> T.pack (show a) <> "</value>"
-- | HTML encoding for 'Text' — wraps in a minimal HTML document.
instance FormatEncoder HtmlFormat Text where
formatEncode t =
TE.encodeUtf8 $ "<html><body>" <> t <> "</body></html>"
-- | A response produced by content negotiation, pairing the selected
-- content type with the serialized body.
data NegotiatedResponse = NegotiatedResponse
{ nrContentType :: !ByteString
, nrBody :: !ByteString
} deriving (Show, Eq)
-- | Parse an @Accept@ header into a list of @(media-type, quality)@ pairs,
-- sorted by descending quality.
--
-- Examples:
--
-- @
-- parseAccept "application/json, text/plain;q=0.9"
-- == [("application/json", 1.0), ("text/plain", 0.9)]
-- @
--
-- Missing quality values default to 1.0. The @*/*@ wildcard is preserved
-- as-is for downstream matching.
parseAccept :: ByteString -> [(ByteString, Double)]
parseAccept hdr
| BS.null hdr = []
| otherwise = sortBy (\a b -> compare (Down (snd a)) (Down (snd b)))
$ map parseEntry (BS8.split ',' hdr)
where
parseEntry :: ByteString -> (ByteString, Double)
parseEntry entry =
case BS8.break (== ';') (trimBS entry) of
(mediaType, rest)
| BS.null rest -> (trimBS mediaType, 1.0)
| otherwise -> (trimBS mediaType, parseQuality rest)
parseQuality :: ByteString -> Double
parseQuality bs =
-- rest starts with ';', so drop it, then look for q=
let params = BS8.drop 1 bs -- drop the ';'
parts = BS8.split ';' params
in case filter (isQParam . trimBS) parts of
(p : _) -> readQ (trimBS p)
[] -> 1.0
isQParam :: ByteString -> Bool
isQParam bs =
let stripped = trimBS bs
in BS8.take 2 stripped == "q=" || BS8.take 2 stripped == "Q="
readQ :: ByteString -> Double
readQ bs =
let valStr = BS8.unpack (BS8.drop 2 bs)
cleaned = takeWhile (\c -> isDigit c || c == '.') valStr
in case reads cleaned of
((d, _) : _) -> d
[] -> 1.0
trimBS :: ByteString -> ByteString
trimBS = BS8.dropWhile isSpaceByte . BS8.reverse . BS8.dropWhile isSpaceByte . BS8.reverse
isSpaceByte :: Char -> Bool
isSpaceByte = isSpace
-- | Given a parsed Accept header and a list of available
-- @(content-type, encoder)@ pairs, find the best matching encoder.
--
-- Returns 'Nothing' if no match is found (the caller should produce 406).
matchFormat
:: ByteString -- ^ Raw Accept header
-> [(Text, a -> ByteString)] -- ^ Available formats: (content-type, encoder)
-> Maybe (Text, a -> ByteString) -- ^ Best match
matchFormat acceptHdr available
| BS.null acceptHdr = case available of
[] -> Nothing
(f:_) -> Just f
| otherwise =
let parsed = parseAccept acceptHdr
in firstMatch parsed available
where
firstMatch :: [(ByteString, Double)] -> [(Text, a -> ByteString)] -> Maybe (Text, a -> ByteString)
firstMatch [] avail = Nothing
firstMatch ((mediaType, _q) : rest) avail =
case findAvailable mediaType avail of
Just found -> Just found
Nothing
| mediaType == "*/*" -> case avail of
[] -> Nothing
(f:_) -> Just f
| isWildcardSubtype mediaType -> findWildcardMatch mediaType avail rest
| otherwise -> firstMatch rest avail
findAvailable :: ByteString -> [(Text, a -> ByteString)] -> Maybe (Text, a -> ByteString)
findAvailable _ [] = Nothing
findAvailable mt ((ct, enc) : rest)
| TE.encodeUtf8 ct == mt = Just (ct, enc)
| otherwise = findAvailable mt rest
isWildcardSubtype :: ByteString -> Bool
isWildcardSubtype bs = BS8.isSuffixOf "/*" bs
findWildcardMatch :: ByteString -> [(Text, a -> ByteString)] -> [(ByteString, Double)] -> Maybe (Text, a -> ByteString)
findWildcardMatch wildcard avail rest =
let prefix = BS8.takeWhile (/= '/') wildcard -- e.g. "text" from "text/*"
in case filter (\(ct, _) -> TE.encodeUtf8 (T.takeWhile (/= '/') ct) == prefix) avail of
(f:_) -> Just f
[] -> firstMatch rest avail
-- | Perform content negotiation: given an Accept header, a list of
-- available formats, and a value, produce an HTTP response.
--
-- If no format matches, returns 406 Not Acceptable.
negotiate
:: ByteString -- ^ Raw Accept header
-> [(Text, a -> ByteString)] -- ^ Available formats: (content-type, encoder)
-> a -- ^ The value to serialize
-> Response ByteString
negotiate acceptHdr available val =
case matchFormat acceptHdr available of
Just (ct, encoder) ->
Response status200
[("Content-Type", TE.encodeUtf8 ct)]
(encoder val)
Nothing ->
Response status406
[("Content-Type", "text/plain")]
"406 Not Acceptable"