Skip to content

Commit cffa511

Browse files
committed
Add QueryParamForm for Client, Server, Internal, Foreign, and SafeLink
1 parent 38f3da2 commit cffa511

File tree

19 files changed

+469
-22
lines changed

19 files changed

+469
-22
lines changed

servant-client-core/servant-client-core.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ library
7979
, base64-bytestring >= 1.0.0.1 && < 1.1
8080
, exceptions >= 0.10.0 && < 0.11
8181
, free >= 5.1 && < 5.2
82+
, http-api-data >= 0.4 && < 0.4.2
8283
, http-media >= 0.7.1.3 && < 0.9
8384
, http-types >= 0.12.2 && < 0.13
8485
, network-uri >= 2.6.1.0 && < 2.7

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ module Servant.Client.Core
5555
, addHeader
5656
, appendToQueryString
5757
, appendToPath
58+
, concatQueryString
5859
, setRequestBodyLBS
5960
, setRequestBody
6061
) where

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import Servant.API
4848
FromSourceIO (..), Header', Headers (..), HttpVersion,
4949
IsSecure, MimeRender (mimeRender),
5050
MimeUnrender (mimeUnrender), NoContent (NoContent), QueryFlag,
51-
QueryParam', QueryParams, Raw, ReflectMethod (..), RemoteHost,
51+
QueryParam', QueryParams, QueryParamForm', Raw, ReflectMethod (..), RemoteHost,
5252
ReqBody', SBoolI, Stream, StreamBody', Summary, ToHttpApiData,
5353
ToSourceIO (..), Vault, Verb, NoContentVerb, WithNamedContext,
5454
contentType, getHeadersHList, getResponse, toQueryParam,
@@ -57,6 +57,8 @@ import Servant.API.ContentTypes
5757
(contentTypes)
5858
import Servant.API.Modifiers
5959
(FoldRequired, RequiredArgument, foldRequiredArgument)
60+
import Web.FormUrlEncoded
61+
(ToForm (..))
6062

6163
import Servant.Client.Core.Auth
6264
import Servant.Client.Core.BasicAuth
@@ -534,6 +536,55 @@ instance (KnownSymbol sym, HasClient m api)
534536
hoistClientMonad pm _ f cl = \b ->
535537
hoistClientMonad pm (Proxy :: Proxy api) f (cl b)
536538

539+
-- | If you use a 'QueryParamForm' in one of your endpoints in your API,
540+
-- the corresponding querying function will automatically take
541+
-- an additional argument of the type specified by your 'QueryParamForm',
542+
-- enclosed in Maybe.
543+
--
544+
-- If you give Nothing, nothing will be added to the query string.
545+
--
546+
-- If you give a non-'Nothing' value, this function will take care
547+
-- of inserting a textual representation of your form in the query string.
548+
--
549+
-- You can control how values for your type are turned into
550+
-- text by specifying a 'ToForm' instance for your type.
551+
-- Example:
552+
--
553+
-- > data BookSearchParams = BookSearchParams
554+
-- > { title :: Text
555+
-- > , authors :: [Text]
556+
-- > , page :: Maybe Int
557+
-- > } deriving (Eq, Show, Generic)
558+
-- > instance ToForm BookSearchParams
559+
--
560+
-- > type MyApi = "books" :> QueryParamForm "searchQ" BookSearchParams :> Get '[JSON] [Book]
561+
-- >
562+
-- > myApi :: Proxy MyApi
563+
-- > myApi = Proxy
564+
-- >
565+
-- > getBooks :: Bool -> ClientM [Book]
566+
-- > getBooks = client myApi
567+
-- > -- then you can just use "getBooks" to query that endpoint.
568+
-- > -- 'getBooksBy Nothing' for all books
569+
-- > -- 'getBooksBy (Just $ BookSearchParams "white noise" ["DeLillo"] Nothing)'
570+
instance (KnownSymbol sym, ToForm a, HasClient m api, SBoolI (FoldRequired mods))
571+
=> HasClient m (QueryParamForm' mods sym a :> api) where
572+
573+
type Client m (QueryParamForm' mods sym a :> api) =
574+
RequiredArgument mods a -> Client m api
575+
576+
-- if mparam = Nothing, we don't add it to the query string
577+
clientWithRoute pm Proxy req mparam =
578+
clientWithRoute pm (Proxy :: Proxy api) $ foldRequiredArgument
579+
(Proxy :: Proxy mods) add (maybe req add) mparam
580+
where
581+
add :: ToForm a => a -> Request
582+
add qForm = concatQueryString qForm req
583+
584+
hoistClientMonad pm _ f cl = \arg ->
585+
hoistClientMonad pm (Proxy :: Proxy api) f (cl arg)
586+
587+
537588
-- | Pick a 'Method' and specify where the server you want to query is. You get
538589
-- back the full `Response`.
539590
instance RunClient m => HasClient m Raw where
@@ -710,4 +761,4 @@ decodedAs response ct = do
710761
Left err -> throwClientError $ DecodeFailure (T.pack err) response
711762
Right val -> return val
712763
where
713-
accept = toList $ contentTypes ct
764+
accept = toList $ contentTypes ct

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module Servant.Client.Core.Request (
1717
addHeader,
1818
appendToPath,
1919
appendToQueryString,
20+
concatQueryString,
2021
setRequestBody,
2122
setRequestBodyLBS,
2223
) where
@@ -50,9 +51,11 @@ import Network.HTTP.Media
5051
(MediaType)
5152
import Network.HTTP.Types
5253
(Header, HeaderName, HttpVersion (..), Method, QueryItem,
53-
http11, methodGet)
54+
http11, methodGet, parseQuery)
5455
import Servant.API
5556
(ToHttpApiData, toEncodedUrlPiece, toHeader, SourceIO)
57+
import Web.FormUrlEncoded
58+
(ToForm (..), urlEncodeAsForm)
5659

5760
import Servant.Client.Core.Internal (mediaTypeRnf)
5861

@@ -135,6 +138,14 @@ addHeader :: ToHttpApiData a => HeaderName -> a -> Request -> Request
135138
addHeader name val req
136139
= req { requestHeaders = requestHeaders req Seq.|> (name, toHeader val)}
137140

141+
concatQueryString :: ToForm a
142+
=> a
143+
-> Request
144+
-> Request
145+
concatQueryString form req
146+
= let querySeq = Seq.fromList . parseQuery . LBS.toStrict . urlEncodeAsForm $ form
147+
in req { requestQueryString = requestQueryString req Seq.>< querySeq }
148+
138149
-- | Set body and media type of the request being constructed.
139150
--
140151
-- The body is set to the given bytestring using the 'RequestBodyLBS'

servant-docs/servant-docs.cabal

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ library
6262
, base-compat >= 0.10.5 && < 0.12
6363
, case-insensitive >= 1.2.0.11 && < 1.3
6464
, hashable >= 1.2.7.0 && < 1.4
65+
, http-api-data >= 0.4 && < 0.4.2
6566
, http-media >= 0.7.1.3 && < 0.9
6667
, http-types >= 0.12.2 && < 0.13
6768
, lens >= 4.17 && < 4.19
@@ -100,6 +101,7 @@ test-suite spec
100101
base
101102
, base-compat
102103
, aeson
104+
, http-api-data
103105
, lens
104106
, servant
105107
, servant-docs

servant-docs/src/Servant/Docs/Internal.hs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ import Control.Lens
2828
(makeLenses, mapped, over, traversed, view, (%~), (&), (.~),
2929
(<>~), (^.), (|>))
3030
import qualified Data.ByteString.Char8 as BSC
31+
import qualified Data.ByteString.Lazy.Char8 as LBSC
3132
import Data.ByteString.Lazy.Char8
3233
(ByteString)
3334
import qualified Data.CaseInsensitive as CI
35+
import Data.Data
36+
(Data, toConstr, constrFields)
3437
import Data.Foldable
3538
(toList)
3639
import Data.Foldable
@@ -63,6 +66,8 @@ import GHC.TypeLits
6366
import Servant.API
6467
import Servant.API.ContentTypes
6568
import Servant.API.TypeLevel
69+
import Web.FormUrlEncoded
70+
(ToForm(..), urlEncodeAsForm)
6671

6772
import qualified Data.Universe.Helpers as U
6873

@@ -950,6 +955,27 @@ instance (KnownSymbol sym, ToParam (QueryFlag sym), HasDocs api)
950955
paramP = Proxy :: Proxy (QueryFlag sym)
951956
action' = over params (|> toParam paramP) action
952957

958+
-- | The docs for a @'QueryParamForm' sym a'@
959+
-- require the following instances for the `a`:
960+
-- 'Data', 'ToSample'
961+
instance (KnownSymbol sym, Data a, ToForm a, ToSample a, HasDocs api)
962+
=> HasDocs (QueryParamForm' mods sym a :> api) where
963+
964+
docsFor Proxy (endpoint, action) =
965+
docsFor subApiP (endpoint, action')
966+
967+
where subApiP = Proxy :: Proxy api
968+
action' =
969+
let (Just sampleForm) = toSample (Proxy :: Proxy a)
970+
paramNames = constrFields (toConstr sampleForm)
971+
sampleEncoding = LBSC.unpack . urlEncodeAsForm . toForm $ sampleForm
972+
in action & params <>~ (fmap (qParamMaker sampleEncoding) paramNames)
973+
qParamMaker formEncodedSample pName = DocQueryParam {
974+
_paramName = pName
975+
, _paramValues = [formEncodedSample]
976+
, _paramDesc = "Query parameter"
977+
, _paramKind = Normal
978+
}
953979

954980
instance HasDocs Raw where
955981
docsFor _proxy (endpoint, action) _ =

servant-docs/test/Servant/DocsSpec.hs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE DeriveDataTypeable #-}
23
{-# LANGUAGE DeriveFunctor #-}
34
{-# LANGUAGE DeriveGeneric #-}
45
{-# LANGUAGE FlexibleContexts #-}
@@ -21,6 +22,8 @@ import Control.Monad
2122
import Control.Monad.Trans.Writer
2223
(Writer, runWriter, tell)
2324
import Data.Aeson
25+
import Data.Data
26+
(Data)
2427
import Data.List
2528
(isInfixOf)
2629
import Data.Proxy
@@ -35,6 +38,8 @@ import Test.Tasty.Golden
3538
(goldenVsString)
3639
import Test.Tasty.HUnit
3740
(Assertion, HasCallStack, assertFailure, testCase, (@?=))
41+
import Web.FormUrlEncoded
42+
(ToForm)
3843

3944
import Servant.API
4045
import Servant.Docs.Internal
@@ -52,6 +57,8 @@ instance ToParam (QueryParam' mods "bar" Int) where
5257
toParam _ = DocQueryParam "bar" ["1","2","3"] "QueryParams Int" Normal
5358
instance ToParam (QueryParams "foo" Int) where
5459
toParam _ = DocQueryParam "foo" ["1","2","3"] "QueryParams Int" List
60+
instance ToParam (QueryParam "query" String) where
61+
toParam _ = DocQueryParam "query" ["a","b","c"] "QueryParams String" Normal
5562
instance ToParam (QueryFlag "foo") where
5663
toParam _ = DocQueryParam "foo" [] "QueryFlag" Flag
5764
instance ToCapture (Capture "foo" Int) where
@@ -76,7 +83,7 @@ spec = describe "Servant.Docs" $ do
7683
(defAction & notes <>~ [DocNote "Get an Integer" ["get an integer in Json or plain text"]])
7784
<>
7885
extraInfo
79-
(Proxy :: Proxy (ReqBody '[JSON] String :> Post '[JSON] Datatype1))
86+
(Proxy :: Proxy ("postJson" :> ReqBody '[JSON] String :> Post '[JSON] Datatype1))
8087
(defAction & notes <>~ [DocNote "Post data" ["Posts some Json data"]])
8188
md = markdown (docsWith defaultDocOptions [] extra (Proxy :: Proxy TestApi1))
8289
tests md
@@ -119,6 +126,12 @@ spec = describe "Servant.Docs" $ do
119126
md `shouldContain` "## POST"
120127
md `shouldContain` "## GET"
121128

129+
it "should mention the endpoints" $ do
130+
md `shouldContain` "## POST /postJson"
131+
md `shouldContain` "## GET /qparam"
132+
md `shouldContain` "## GET /qparamform"
133+
md `shouldContain` "## PUT /header"
134+
122135
it "mentions headers" $ do
123136
md `shouldContain` "- This endpoint is sensitive to the value of the **X-Test** HTTP header."
124137

@@ -127,6 +140,15 @@ spec = describe "Servant.Docs" $ do
127140
it "contains request body samples" $
128141
md `shouldContain` "17"
129142

143+
it "mentions optional query-param" $ do
144+
md `shouldContain` "### GET Parameters:"
145+
md `shouldContain` "- query"
146+
it "mentions optional query-param-form params from QueryParamForm" $ do
147+
md `shouldContain` "- dt1field1"
148+
md `shouldContain` "- dt1field2"
149+
-- contains sample url-encoded form
150+
md `shouldContain` "- **Values**: *dt1field1=field%201&dt1field2=13*"
151+
130152
it "does not generate any docs mentioning the 'empty-api' path" $
131153
md `shouldNotContain` "empty-api"
132154

@@ -135,9 +157,10 @@ spec = describe "Servant.Docs" $ do
135157

136158
data Datatype1 = Datatype1 { dt1field1 :: String
137159
, dt1field2 :: Int
138-
} deriving (Eq, Show, Generic)
160+
} deriving (Eq, Show, Data, Generic)
139161

140162
instance ToJSON Datatype1
163+
instance ToForm Datatype1
141164

142165
instance ToSample Datatype1 where
143166
toSamples _ = singleSample $ Datatype1 "field 1" 13
@@ -152,9 +175,11 @@ instance MimeRender PlainText Int where
152175
mimeRender _ = cs . show
153176

154177
type TestApi1 = Get '[JSON, PlainText] (Headers '[Header "Location" String] Int)
155-
:<|> ReqBody '[JSON] String :> Post '[JSON] Datatype1
156-
:<|> Header "X-Test" Int :> Put '[JSON] Int
157-
:<|> "empty-api" :> EmptyAPI
178+
:<|> "postJson" :> ReqBody '[JSON] String :> Post '[JSON] Datatype1
179+
:<|> "qparam" :> QueryParam "query" String :> Get '[JSON] Datatype1
180+
:<|> "qparamform" :> QueryParamForm "form" Datatype1 :> Get '[JSON] Datatype1
181+
:<|> "header" :> Header "X-Test" Int :> Put '[JSON] Int
182+
:<|> "empty-api" :> EmptyAPI
158183

159184
data TT = TT1 | TT2 deriving (Show, Eq)
160185
data UT = UT1 | UT2 deriving (Show, Eq)

servant-foreign/src/Servant/Foreign/Internal.hs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ data ArgType
8888
= Normal
8989
| Flag
9090
| List
91+
| Form
9192
deriving (Data, Eq, Show, Typeable)
9293

9394
makePrisms ''ArgType
@@ -324,6 +325,19 @@ instance
324325
{ _argName = PathSegment str
325326
, _argType = typeFor lang ftype (Proxy :: Proxy Bool) }
326327

328+
instance (KnownSymbol sym, HasForeignType lang ftype (RequiredArgument mods a), HasForeign lang ftype api)
329+
=> HasForeign lang ftype (QueryParamForm' mods sym a :> api) where
330+
type Foreign ftype (QueryParamForm' mods sym a :> api) = Foreign ftype api
331+
332+
foreignFor lang Proxy Proxy req =
333+
foreignFor lang (Proxy :: Proxy ftype) (Proxy :: Proxy api) $
334+
req & reqUrl.queryStr <>~ [QueryArg arg Form]
335+
where
336+
arg = Arg
337+
{ _argName = PathSegment ""
338+
, _argType = typeFor lang (Proxy :: Proxy ftype) (Proxy :: Proxy (RequiredArgument mods a)) }
339+
340+
327341
instance HasForeign lang ftype Raw where
328342
type Foreign ftype Raw = HTTP.Method -> Req ftype
329343

servant-foreign/test/Servant/ForeignSpec.hs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,21 @@ instance {-# OVERLAPPABLE #-} HasForeignType LangX String a => HasForeignType La
6868
instance (HasForeignType LangX String a) => HasForeignType LangX String (Maybe a) where
6969
typeFor lang ftype _ = "maybe " <> typeFor lang ftype (Proxy :: Proxy a)
7070

71+
data ContactForm = ContactForm {
72+
name :: String
73+
, message :: String
74+
, email :: String
75+
} deriving (Eq, Show)
76+
77+
instance HasForeignType LangX String ContactForm where
78+
typeFor _ _ _ = "contactFormX"
79+
80+
81+
7182
type TestApi
7283
= "test" :> Header "header" [String] :> QueryFlag "flag" :> Get '[JSON] Int
7384
:<|> "test" :> QueryParam "param" Int :> ReqBody '[JSON] [String] :> Post '[JSON] NoContent
85+
:<|> "test" :> QueryParamForm "contact" ContactForm :> Post '[JSON] NoContent
7486
:<|> "test" :> QueryParams "params" Int :> ReqBody '[JSON] String :> Put '[JSON] NoContent
7587
:<|> "test" :> Capture "id" Int :> Delete '[JSON] NoContent
7688
:<|> "test" :> CaptureAll "ids" Int :> Get '[JSON] [Int]
@@ -82,9 +94,9 @@ testApi = listFromAPI (Proxy :: Proxy LangX) (Proxy :: Proxy String) (Proxy :: P
8294
listFromAPISpec :: Spec
8395
listFromAPISpec = describe "listFromAPI" $ do
8496
it "generates 5 endpoints for TestApi" $ do
85-
length testApi `shouldBe` 5
97+
length testApi `shouldBe` 6
8698

87-
let [getReq, postReq, putReq, deleteReq, captureAllReq] = testApi
99+
let [getReq, postReq, contactReq, putReq, deleteReq, captureAllReq] = testApi
88100

89101
it "collects all info for get request" $ do
90102
shouldBe getReq $ defReq
@@ -110,6 +122,17 @@ listFromAPISpec = describe "listFromAPI" $ do
110122
, _reqFuncName = FunctionName ["post", "test"]
111123
}
112124

125+
it "collects all info for a queryparamform" $ do
126+
shouldBe contactReq $ defReq
127+
{ _reqUrl = Url
128+
[ Segment $ Static "test" ]
129+
[ QueryArg (Arg "" "maybe contactFormX") Form ]
130+
, _reqMethod = "POST"
131+
, _reqHeaders = []
132+
, _reqReturnType = Just "voidX"
133+
, _reqFuncName = FunctionName ["post", "test"]
134+
}
135+
113136
it "collects all info for put request" $ do
114137
shouldBe putReq $ defReq
115138
{ _reqUrl = Url
@@ -148,3 +171,4 @@ listFromAPISpec = describe "listFromAPI" $ do
148171
, _reqReturnType = Just "listX of intX"
149172
, _reqFuncName = FunctionName ["get", "test", "by", "ids"]
150173
}
174+

0 commit comments

Comments
 (0)