Skip to content

Commit 8dec489

Browse files
committed
Add support for full & deep query string capture in servant-server
1 parent a5357ea commit 8dec489

File tree

11 files changed

+325
-20
lines changed

11 files changed

+325
-20
lines changed

servant-client-core/src/Servant/Client/Core/HasClient.hs

+51-11
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module Servant.Client.Core.HasClient (
2323
(/:),
2424
foldMapUnion,
2525
matchUnion,
26+
ToDeepQuery (..)
2627
) where
2728

2829
import Prelude ()
@@ -46,6 +47,7 @@ import Data.List
4647
import Data.Sequence
4748
(fromList)
4849
import qualified Data.Text as T
50+
import Data.Text.Encoding (encodeUtf8)
4951
import Network.HTTP.Media
5052
(MediaType, matches, parseAccept)
5153
import qualified Network.HTTP.Media as Media
@@ -70,17 +72,17 @@ import Network.HTTP.Types
7072
(Status)
7173
import qualified Network.HTTP.Types as H
7274
import Servant.API
73-
((:<|>) ((:<|>)), (:>), AuthProtect, BasicAuth, BasicAuthData,
74-
BuildHeadersTo (..), Capture', CaptureAll, Description,
75-
EmptyAPI, Fragment, FramingRender (..), FramingUnrender (..),
76-
FromSourceIO (..), Header', Headers (..), HttpVersion,
77-
IsSecure, MimeRender (mimeRender),
78-
MimeUnrender (mimeUnrender), NoContent (NoContent),
79-
NoContentVerb, QueryFlag, QueryParam', QueryParams, Raw, RawM,
80-
ReflectMethod (..), RemoteHost, ReqBody', SBoolI, Stream,
81-
StreamBody', Summary, ToHttpApiData, ToSourceIO (..), Vault,
82-
Verb, WithNamedContext, WithResource, WithStatus (..), contentType, getHeadersHList,
83-
getResponse, toEncodedUrlPiece, toUrlPiece, NamedRoutes)
75+
((:<|>) ((:<|>)), (:>),
76+
BuildHeadersTo (..),
77+
EmptyAPI,
78+
FromSourceIO (..),
79+
IsSecure,
80+
MimeUnrender (mimeUnrender),
81+
NoContentVerb,
82+
ReflectMethod (..),
83+
StreamBody',
84+
Verb,
85+
getResponse, AuthProtect, BasicAuth, BasicAuthData, Capture', CaptureAll, DeepQuery, Description, Fragment, FramingRender (..), FramingUnrender (..), Header', Headers (..), HttpVersion, MimeRender (mimeRender), NoContent (NoContent), QueryFlag, QueryParam', QueryParams, QueryString, Raw, RawM, RemoteHost, ReqBody', SBoolI, Stream, Summary, ToHttpApiData, ToSourceIO (..), Vault, WithNamedContext, WithResource, WithStatus (..), contentType, getHeadersHList, toEncodedUrlPiece, toUrlPiece, NamedRoutes)
8486
import Servant.API.Generic
8587
(GenericMode(..), ToServant, ToServantApi
8688
, GenericServant, toServant, fromServant)
@@ -664,6 +666,44 @@ instance (KnownSymbol sym, HasClient m api)
664666
hoistClientMonad pm _ f cl = \b ->
665667
hoistClientMonad pm (Proxy :: Proxy api) f (cl b)
666668

669+
instance (HasClient m api)
670+
=> HasClient m (QueryString :> api) where
671+
type Client m (QueryString :> api) =
672+
H.Query -> Client m api
673+
674+
clientWithRoute pm Proxy req query =
675+
clientWithRoute pm (Proxy :: Proxy api)
676+
(setQueryString query req)
677+
678+
hoistClientMonad pm _ f cl = \b ->
679+
hoistClientMonad pm (Proxy :: Proxy api) f (cl b)
680+
681+
class ToDeepQuery a where
682+
toDeepQuery :: a -> [([T.Text], Maybe T.Text)]
683+
684+
generateDeepParam :: T.Text -> ([T.Text], Maybe T.Text) -> (T.Text, Maybe T.Text)
685+
generateDeepParam name (keys, value) =
686+
let makeKeySegment key = "[" <> key <> "]"
687+
in (name <> foldMap makeKeySegment keys, value)
688+
689+
instance (KnownSymbol sym, ToDeepQuery a, HasClient m api)
690+
=> HasClient m (DeepQuery sym a :> api) where
691+
type Client m (DeepQuery sym a :> api) =
692+
a -> Client m api
693+
694+
clientWithRoute pm Proxy req deepObject =
695+
let params = toDeepQuery deepObject
696+
withParams = foldl' addDeepParam req params
697+
addDeepParam r' kv =
698+
let (k, textV) = generateDeepParam paramname kv
699+
in appendToQueryString k (encodeUtf8 <$> textV) r'
700+
paramname = pack $ symbolVal (Proxy :: Proxy sym)
701+
in clientWithRoute pm (Proxy :: Proxy api)
702+
withParams
703+
704+
hoistClientMonad pm _ f cl = \b ->
705+
hoistClientMonad pm (Proxy :: Proxy api) f (cl b)
706+
667707
-- | Pick a 'Method' and specify where the server you want to query is. You get
668708
-- back the full `Response`.
669709
instance RunClient m => HasClient m Raw where

servant-client-core/src/Servant/Client/Core/Reexport.hs

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ module Servant.Client.Core.Reexport
2020
, ClientError(..)
2121
, EmptyClient(..)
2222

23+
-- * DeepQuery
24+
, ToDeepQuery(..)
25+
2326
-- * BaseUrl
2427
, BaseUrl(..)
2528
, Scheme(..)

servant-client-core/src/Servant/Client/Core/Request.hs

+8-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module Servant.Client.Core.Request (
1818
appendToPath,
1919
appendToQueryString,
2020
encodeQueryParamValue,
21+
setQueryString,
2122
setRequestBody,
2223
setRequestBodyLBS,
2324
) where
@@ -50,7 +51,7 @@ import GHC.Generics
5051
import Network.HTTP.Media
5152
(MediaType)
5253
import Network.HTTP.Types
53-
(Header, HeaderName, HttpVersion (..), Method, QueryItem,
54+
(Header, HeaderName, HttpVersion (..), Method, Query, QueryItem,
5455
http11, methodGet, urlEncodeBuilder)
5556
import Servant.API
5657
(ToHttpApiData, toEncodedUrlPiece, toQueryParam, toHeader, SourceIO)
@@ -162,6 +163,12 @@ appendToQueryString pname pvalue req
162163
= req { requestQueryString = requestQueryString req
163164
Seq.|> (encodeUtf8 pname, pvalue)}
164165

166+
setQueryString :: Query
167+
-> Request
168+
-> Request
169+
setQueryString query req
170+
= req { requestQueryString = Seq.fromList query }
171+
165172
-- | Encode a query parameter value.
166173
--
167174
encodeQueryParamValue :: ToHttpApiData a => a -> BS.ByteString

servant-client/test/Servant/ClientTestUtils.hs

+39-3
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ import Control.Monad.Error.Class
3131
import Data.Aeson
3232
import Data.ByteString
3333
(ByteString)
34+
import qualified Data.ByteString.Char8 as C8
3435
import Data.ByteString.Builder
3536
(byteString)
3637
import qualified Data.ByteString.Lazy as LazyByteString
3738
import Data.Char
3839
(chr, isPrint)
40+
import Data.Maybe (fromMaybe)
3941
import Data.Monoid ()
4042
import Data.Proxy
4143
import Data.SOP
@@ -54,17 +56,18 @@ import Network.Wai.Handler.Warp
5456
import System.IO.Unsafe
5557
(unsafePerformIO)
5658
import Test.QuickCheck
59+
import Text.Read (readMaybe)
5760
import Web.FormUrlEncoded
5861
(FromForm, ToForm)
5962

6063
import Servant.API
6164
((:<|>) ((:<|>)), (:>), AuthProtect, BasicAuth,
62-
BasicAuthData (..), Capture, CaptureAll, DeleteNoContent,
65+
BasicAuthData (..), Capture, CaptureAll, DeepQuery, DeleteNoContent,
6366
EmptyAPI, FormUrlEncoded, Fragment, FromHttpApiData (..), Get, Header, Headers,
6467
JSON, MimeRender (mimeRender), MimeUnrender (mimeUnrender),
6568
NoContent (NoContent), PlainText, Post, QueryFlag, QueryParam,
66-
QueryParams, Raw, ReqBody, StdMethod (GET), ToHttpApiData (..), UVerb, Union,
67-
Verb, WithStatus (WithStatus), NamedRoutes, addHeader)
69+
QueryParams, QueryString, Raw, ReqBody, StdMethod (GET), ToHttpApiData (..),
70+
UVerb, Union, Verb, WithStatus (WithStatus), NamedRoutes, addHeader)
6871
import Servant.API.Generic ((:-))
6972
import Servant.Client
7073
import qualified Servant.Client.Core.Auth as Auth
@@ -122,6 +125,25 @@ data OtherRoutes mode = OtherRoutes
122125
-- Get for HTTP 307 Temporary Redirect
123126
type Get307 = Verb 'GET 307
124127

128+
data Filter = Filter
129+
{ ageFilter :: Integer
130+
, nameFilter :: String
131+
}
132+
deriving Show
133+
134+
instance FromDeepQuery Filter where
135+
fromDeepQuery params = do
136+
let maybeToRight l = maybe (Left l) Right
137+
age' <- maybeToRight "missing age" $ readMaybe . Text.unpack =<< join (lookup ["age"] params)
138+
name' <- maybeToRight "missing name" $ join $ lookup ["name"] params
139+
return $ Filter age' (Text.unpack name')
140+
141+
instance ToDeepQuery Filter where
142+
toDeepQuery (Filter age' name') =
143+
[ (["age"], Just (Text.pack $ show age'))
144+
, (["name"], Just (Text.pack name'))
145+
]
146+
125147
type Api =
126148
Get '[JSON] Person
127149
:<|> "get" :> Get '[JSON] Person
@@ -140,6 +162,8 @@ type Api =
140162
:<|> "param-binary" :> QueryParam "payload" UrlEncodedByteString :> Raw
141163
:<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person]
142164
:<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool
165+
:<|> "query-string" :> QueryString :> Get '[JSON] Person
166+
:<|> "deep-query" :> DeepQuery "filter" Filter :> Get '[JSON] Person
143167
:<|> "fragment" :> Fragment String :> Get '[JSON] Person
144168
:<|> "rawSuccess" :> Raw
145169
:<|> "rawSuccessPassHeaders" :> Raw
@@ -178,6 +202,8 @@ getQueryParam :: Maybe String -> ClientM Person
178202
getQueryParamBinary :: Maybe UrlEncodedByteString -> HTTP.Method -> ClientM Response
179203
getQueryParams :: [String] -> ClientM [Person]
180204
getQueryFlag :: Bool -> ClientM Bool
205+
getQueryString :: [(ByteString, Maybe ByteString)] -> ClientM Person
206+
getDeepQuery :: Filter -> ClientM Person
181207
getFragment :: ClientM Person
182208
getRawSuccess :: HTTP.Method -> ClientM Response
183209
getRawSuccessPassHeaders :: HTTP.Method -> ClientM Response
@@ -206,6 +232,8 @@ getRoot
206232
:<|> getQueryParamBinary
207233
:<|> getQueryParams
208234
:<|> getQueryFlag
235+
:<|> getQueryString
236+
:<|> getDeepQuery
209237
:<|> getFragment
210238
:<|> getRawSuccess
211239
:<|> getRawSuccessPassHeaders
@@ -244,6 +272,14 @@ server = serve api (
244272
)
245273
:<|> (\ names -> return (zipWith Person names [0..]))
246274
:<|> return
275+
:<|> (\ q -> return alice { _name = maybe mempty C8.unpack $ join (lookup "name" q)
276+
, _age = fromMaybe 0 (readMaybe . C8.unpack =<< join (lookup "age" q))
277+
}
278+
)
279+
:<|> (\ filter' -> return alice { _name = nameFilter filter'
280+
, _age = ageFilter filter'
281+
}
282+
)
247283
:<|> return alice
248284
:<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.ok200 [] "rawSuccess")
249285
:<|> (Tagged $ \ request respond -> (respond $ Wai.responseLBS HTTP.ok200 (Wai.requestHeaders $ request) "rawSuccess"))

servant-client/test/Servant/SuccessSpec.hs

+7
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ successSpec = beforeAll (startWaiApp server) $ afterAll endWaiApp $ do
110110
forM_ [False, True] $ \ flag -> it (show flag) $ \(_, baseUrl) -> do
111111
left show <$> runClient (getQueryFlag flag) baseUrl `shouldReturn` Right flag
112112

113+
it "Servant.API.QueryParam.QueryString" $ \(_, baseUrl) -> do
114+
let qs = [("name", Just "bob"), ("age", Just "1")]
115+
left show <$> runClient (getQueryString qs) baseUrl `shouldReturn` (Right (Person "bob" 1))
116+
117+
it "Servant.API.QueryParam.DeepQuery" $ \(_, baseUrl) -> do
118+
left show <$> runClient (getDeepQuery $ Filter 1 "bob") baseUrl `shouldReturn` (Right (Person "bob" 1))
119+
113120
it "Servant.API.Fragment" $ \(_, baseUrl) -> do
114121
left id <$> runClient getFragment baseUrl `shouldReturn` Right alice
115122

servant-server/src/Servant/Server.hs

+3
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ module Servant.Server
111111

112112
, getAcceptHeader
113113

114+
-- * DeepQuery parsing
115+
, FromDeepQuery (..)
116+
114117
-- * Re-exports
115118
, Application
116119
, Tagged (..)

0 commit comments

Comments
 (0)