Files
postgrest/src/PostgREST/MediaType.hs
T
Andrei DziahelandWolfgang Walther 3026c1f308 fix: Parse accept header case-insensitively
The Accept header is parsed case-insensitively now, introducing proper
handling of media types specified in upper- and/or mixed-case.

Fixes #3478
2024-05-09 19:30:42 +02:00

187 lines
7.5 KiB
Haskell

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.MediaType
( MediaType(..)
, MTVndPlanOption (..)
, MTVndPlanFormat (..)
, toContentType
, toMime
, decodeMediaType
) where
import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS
import Network.HTTP.Types.Header (Header, hContentType)
import Data.Map (fromList, (!?))
import qualified Data.Text as T (break, drop, dropWhile,
dropWhileEnd, null, splitOn,
toLower)
import Data.Text.Encoding (decodeLatin1)
import Protolude
-- | Enumeration of currently supported media types
data MediaType
= MTApplicationJSON
| MTGeoJSON
| MTTextCSV
| MTTextPlain
| MTTextXML
| MTOpenAPI
| MTUrlEncoded
| MTOctetStream
| MTAny
| MTOther Text
-- vendored media types
| MTVndArrayJSONStrip
| MTVndSingularJSON Bool
-- TODO MTVndPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
| MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]
deriving (Eq, Show, Generic, JSON.ToJSON)
instance Hashable MediaType
data MTVndPlanOption
= PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL
deriving (Eq, Show, Generic, JSON.ToJSON)
instance Hashable MTVndPlanOption
data MTVndPlanFormat
= PlanJSON | PlanText
deriving (Eq, Show, Generic, JSON.ToJSON)
instance Hashable MTVndPlanFormat
-- | Convert MediaType to a Content-Type HTTP Header
toContentType :: MediaType -> Header
toContentType ct = (hContentType, toMime ct <> charset)
where
charset = case ct of
MTOctetStream -> mempty
MTOther _ -> mempty
_ -> "; charset=utf-8"
-- | Convert from MediaType to a ByteString representing the mime type
toMime :: MediaType -> ByteString
toMime MTApplicationJSON = "application/json"
toMime MTVndArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
toMime MTGeoJSON = "application/geo+json"
toMime MTTextCSV = "text/csv"
toMime MTTextPlain = "text/plain"
toMime MTTextXML = "text/xml"
toMime MTOpenAPI = "application/openapi+json"
toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"
toMime MTUrlEncoded = "application/x-www-form-urlencoded"
toMime MTOctetStream = "application/octet-stream"
toMime MTAny = "*/*"
toMime (MTOther ct) = encodeUtf8 ct
toMime (MTVndPlan mt fmt opts) =
"application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
("; for=\"" <> toMime mt <> "\"") <>
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
toMimePlanOption :: MTVndPlanOption -> ByteString
toMimePlanOption PlanAnalyze = "analyze"
toMimePlanOption PlanVerbose = "verbose"
toMimePlanOption PlanSettings = "settings"
toMimePlanOption PlanBuffers = "buffers"
toMimePlanOption PlanWAL = "wal"
toMimePlanFormat :: MTVndPlanFormat -> ByteString
toMimePlanFormat PlanJSON = "json"
toMimePlanFormat PlanText = "text"
-- | Convert from ByteString to MediaType.
--
-- >>> decodeMediaType "application/json"
-- MTApplicationJSON
--
-- >>> decodeMediaType "application/vnd.pgrst.plan;"
-- MTVndPlan MTApplicationJSON PlanText []
--
-- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\""
-- MTVndPlan MTApplicationJSON PlanText []
--
-- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
-- MTVndPlan MTTextCSV PlanJSON []
--
-- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped"
-- MTVndArrayJSONStrip
--
-- >>> decodeMediaType "application/vnd.pgrst.array+json"
-- MTApplicationJSON
--
-- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped"
-- MTVndSingularJSON True
--
-- >>> decodeMediaType "application/vnd.pgrst.object+json"
-- MTVndSingularJSON False
--
-- Test uppercase is parsed correctly (per issue #3478)
-- >>> decodeMediaType "ApplicatIon/vnd.PgRsT.object+json"
-- MTVndSingularJSON False
--
-- >>> decodeMediaType "application/vnd.twkb"
-- MTOther "application/vnd.twkb"
decodeMediaType :: ByteString -> MediaType
decodeMediaType mt = decodeMediaType' $ decodeLatin1 mt
where
decodeMediaType' :: Text -> MediaType
decodeMediaType' mt' =
case (T.toLower mainType, T.toLower subType, params) of
("application", "json", _) -> MTApplicationJSON
("application", "geo+json", _) -> MTGeoJSON
("text", "csv", _) -> MTTextCSV
("text", "plain", _) -> MTTextPlain
("text", "xml", _) -> MTTextXML
("application", "openapi+json", _) -> MTOpenAPI
("application", "x-www-form-urlencoded", _) -> MTUrlEncoded
("application", "octet-stream", _) -> MTOctetStream
("application", "vnd.pgrst.plan", _) -> getPlan PlanText
("application", "vnd.pgrst.plan+text", _) -> getPlan PlanText
("application", "vnd.pgrst.plan+json", _) -> getPlan PlanJSON
("application", "vnd.pgrst.object+json", _) -> MTVndSingularJSON strippedNulls
("application", "vnd.pgrst.object", _) -> MTVndSingularJSON strippedNulls
("application", "vnd.pgrst.array+json", _) -> checkArrayNullStrip
("application", "vnd.pgrst.array", _) -> checkArrayNullStrip
("*","*",_) -> MTAny
_ -> MTOther mt'
where
(mainType, subType, params') = tokenizeMediaType mt'
params = fromList $ map (first T.toLower) params' -- normalize parameter names to lowercase, per RFC 7321
getPlan fmt = MTVndPlan mtFor fmt $
[PlanAnalyze | inOpts "analyze" ] ++
[PlanVerbose | inOpts "verbose" ] ++
[PlanSettings | inOpts "settings"] ++
[PlanBuffers | inOpts "buffers" ] ++
[PlanWAL | inOpts "wal" ]
where
mtFor = decodeMediaType' $ fromMaybe "application/json" (params !? "for")
inOpts str = str `elem` opts
opts = T.splitOn "|" $ fromMaybe mempty (params !? "options")
strippedNulls = fromMaybe "false" (params !? "nulls") == "stripped"
checkArrayNullStrip = if strippedNulls then MTVndArrayJSONStrip else MTApplicationJSON
-- | Split a Media Type string into components
-- >>> tokenizeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
-- ("application","vnd.pgrst.plan+json",[("for","text/csv")])
-- >>> tokenizeMediaType "*/*"
-- ("*","*",[])
-- >>> tokenizeMediaType "application/vnd.pgrst.plan;wat=\"application/json;text/csv\""
-- ("application","vnd.pgrst.plan",[("wat","application/json"),("text/csv\"","")])
tokenizeMediaType :: Text -> (Text, Text, [(Text, Text)])
tokenizeMediaType t = (mainType, subType, params)
where
(mainType, rest) = T.break (== '/') t
(subType, restParams) = T.break (== ';') $ T.drop 1 rest
params =
let rp = T.drop 1 restParams
in if T.null rp then [] else map param $ T.splitOn ";" rp -- FIXME: breaks if there's a ';' in a quoted value
param p =
let (k, v) = T.break (== '=') p
in (k, dropAround (== '"') $ T.drop 1 v) -- FIXME: doesn't unescape quotes in values
dropAround p = T.dropWhile p . T.dropWhileEnd p