Skip to content

Commit 53359cb

Browse files
committed
Parse deep objects from query string
1 parent 982b913 commit 53359cb

File tree

2 files changed

+87
-2
lines changed

2 files changed

+87
-2
lines changed

servant-server/src/Servant/Server/Internal.hs

+86-1
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ import Control.Monad.Trans
3737
(liftIO)
3838
import Control.Monad.Trans.Resource
3939
(runResourceT)
40+
import Data.Bifunctor (first)
4041
import qualified Data.ByteString as B
4142
import qualified Data.ByteString.Builder as BB
4243
import qualified Data.ByteString.Char8 as BC8
4344
import qualified Data.ByteString.Lazy as BL
4445
import Data.Constraint (Constraint, Dict(..))
4546
import Data.Either
4647
(partitionEithers)
48+
import qualified Data.Map.Strict as Map
4749
import Data.Maybe
4850
(fromMaybe, isNothing, mapMaybe, maybeToList)
4951
import Data.String
@@ -70,7 +72,7 @@ import Prelude ()
7072
import Prelude.Compat
7173
import Servant.API
7274
((:<|>) (..), (:>), Accept (..), BasicAuth, Capture',
73-
CaptureAll, Description, EmptyAPI, Fragment,
75+
CaptureAll, DeepQuery, Description, EmptyAPI, Fragment,
7476
FramingRender (..), FramingUnrender (..), FromSourceIO (..),
7577
Header', If, IsSecure (..), NoContentVerb, QueryFlag,
7678
QueryParam', QueryParams, QueryString, Raw, ReflectMethod (reflectMethod),
@@ -610,6 +612,89 @@ instance
610612
route Proxy context subserver =
611613
route (Proxy :: Proxy api) context (passToServer subserver queryString)
612614

615+
-- | If you use @'DeepQuery' "symbol" a@ in one of the endpoints for your API,
616+
-- this automatically requires your server-side handler to be a function
617+
-- that takes an argument of type @a@.
618+
--
619+
-- This lets you extract an object from multiple parameters in the query string,
620+
-- with its fields enclosed in brackets: `/books?filter[author][name]=value`. When
621+
-- all the fields are known in advance, it can be done with @'QueryParam'@ (it can
622+
-- still be tedious if you the object has many fields). When some fields are dynamic,
623+
-- it cannot be done with @'QueryParam'.
624+
--
625+
-- The way the object is constructed from the extracted fields can be controlled by
626+
-- providing an instance on @'FromDeepQuery'@
627+
--
628+
-- Example:
629+
--
630+
-- > type MyApi = "books" :> DeepQuery "filter" BookQuery :> Get '[JSON] [Book]
631+
-- >
632+
-- > server :: Server MyApi
633+
-- > server = getBooksBy
634+
-- > where getBooksBy :: BookQuery -> Handler [Book]
635+
-- > getBooksBy query = ...filter books based on the dynamic filters provided...
636+
instance
637+
( KnownSymbol sym, FromDeepQuery a, HasServer api context
638+
, HasContextEntry (MkContextWithErrorFormatter context) ErrorFormatters
639+
)
640+
=> HasServer (DeepQuery sym a :> api) context where
641+
------
642+
type ServerT (DeepQuery sym a :> api) m =
643+
a -> ServerT api m
644+
645+
hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt . s
646+
647+
route Proxy context subserver = route (Proxy :: Proxy api) context $
648+
subserver `addParameterCheck` withRequest paramsCheck
649+
where
650+
rep = typeRep (Proxy :: Proxy DeepQuery)
651+
formatError = urlParseErrorFormatter $ getContextEntry (mkContextWithErrorFormatter context)
652+
653+
paramname = cs $ symbolVal (Proxy :: Proxy sym)
654+
paramsCheck req =
655+
let relevantParams :: [(T.Text, Maybe T.Text)]
656+
relevantParams = filter (isRelevantParam . fst)
657+
. queryToQueryText
658+
. queryString
659+
$ req
660+
isRelevantParam name = name == paramname || (paramname <> "[") `T.isPrefixOf` name
661+
in case fromDeepQuery =<< traverse parseDeepParam relevantParams of
662+
Left e -> delayedFailFatal $ formatError rep req
663+
$ cs $ "Error parsing deep query parameter(s) "
664+
<> paramname <> T.pack " failed: "
665+
<> T.pack e
666+
Right parsed -> return parsed
667+
668+
parseDeepParam :: (T.Text, Maybe T.Text) -> Either String ([T.Text], Maybe T.Text)
669+
parseDeepParam (paramname, value) =
670+
let parseParam "" = return []
671+
parseParam n = reverse <$> go [] n
672+
go parsed remaining = case T.take 1 remaining of
673+
"[" -> case T.breakOn "]" remaining of
674+
(_ , "") -> Left $ "Error parsing deep param, missing closing ']': " <> T.unpack remaining
675+
(name, "]") -> return $ T.drop 1 name : parsed
676+
(name, remaining') -> case T.take 2 remaining' of
677+
"][" -> go (T.drop 1 name : parsed) (T.drop 1 remaining')
678+
_ -> Left $ "Error parsing deep param, incorrect brackets: " <> T.unpack remaining
679+
_ -> Left $ "Error parsing deep param, missing opening '[': " <> T.unpack remaining
680+
in (, value) <$> parseParam paramname
681+
682+
-- | Extract a deep object from (possibly nested) query parameters.
683+
-- a param like @filter[a][b][c]=d@ will be represented as
684+
-- @'(["a", "b", "c"], Just "d")'@. Note that a parameter with no
685+
-- nested field is possible: @filter=a@ will be represented as
686+
-- @'([], Just "a")'@
687+
class FromDeepQuery a where
688+
fromDeepQuery :: [([T.Text], Maybe T.Text)] -> Either String a
689+
690+
instance FromHttpApiData a => FromDeepQuery (Map.Map T.Text a) where
691+
fromDeepQuery params =
692+
let parseParam ([k], Just rawV) = (k,) <$> first T.unpack (parseQueryParam rawV)
693+
parseParam (_, Nothing) = Left "Empty map value"
694+
parseParam ([], _) = Left "Empty map parameter"
695+
parseParam (_ , Just _) = Left "Nested map values"
696+
in Map.fromList <$> traverse parseParam params
697+
613698
-- | Just pass the request to the underlying application and serve its response.
614699
--
615700
-- Example:

servant/src/Servant/API.hs

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ import Servant.API.Modifiers
117117
import Servant.API.QueryParam
118118
(QueryFlag, QueryParam, QueryParam', QueryParams)
119119
import Servant.API.QueryString
120-
(QueryString)
120+
(QueryString, DeepQuery)
121121
import Servant.API.Raw
122122
(Raw)
123123
import Servant.API.RemoteHost

0 commit comments

Comments
 (0)