diff --git a/servant-client-core/src/Servant/Client/Core.hs b/servant-client-core/src/Servant/Client/Core.hs index cfebbb5ff..4e6667f53 100644 --- a/servant-client-core/src/Servant/Client/Core.hs +++ b/servant-client-core/src/Servant/Client/Core.hs @@ -57,6 +57,7 @@ module Servant.Client.Core , addHeader , appendToQueryString , appendToPath + , concatQueryString , setRequestBodyLBS , setRequestBody ) where diff --git a/servant-client-core/src/Servant/Client/Core/HasClient.hs b/servant-client-core/src/Servant/Client/Core/HasClient.hs index d598bf661..514a0c9ea 100644 --- a/servant-client-core/src/Servant/Client/Core/HasClient.hs +++ b/servant-client-core/src/Servant/Client/Core/HasClient.hs @@ -75,9 +75,9 @@ import Servant.API FromSourceIO (..), Header', Headers (..), HttpVersion, IsSecure, MimeRender (mimeRender), MimeUnrender (mimeUnrender), NoContent (NoContent), - NoContentVerb, QueryFlag, QueryParam', QueryParams, Raw, + NoContentVerb, QueryFlag, QueryParam', QueryParams, QueryParamForm', Raw, ReflectMethod (..), RemoteHost, ReqBody', SBoolI, Stream, - StreamBody', Summary, ToHttpApiData, ToSourceIO (..), Vault, + StreamBody', Summary, ToForm (..), ToHttpApiData, ToSourceIO (..), Vault, Verb, WithNamedContext, WithStatus (..), contentType, getHeadersHList, getResponse, toEncodedUrlPiece, toUrlPiece) import Servant.API.ContentTypes @@ -653,6 +653,55 @@ instance (KnownSymbol sym, HasClient m api) hoistClientMonad pm _ f cl = \b -> hoistClientMonad pm (Proxy :: Proxy api) f (cl b) +-- | If you use a 'QueryParamForm' in one of your endpoints in your API, +-- the corresponding querying function will automatically take +-- an additional argument of the type specified by your 'QueryParamForm', +-- enclosed in Maybe. +-- +-- If you give Nothing, nothing will be added to the query string. +-- +-- If you give a non-'Nothing' value, this function will take care +-- of inserting a textual representation of your form in the query string. +-- +-- You can control how values for your type are turned into +-- text by specifying a 'ToForm' instance for your type. +-- Example: +-- +-- > data BookSearchParams = BookSearchParams +-- > { title :: Text +-- > , authors :: [Text] +-- > , page :: Maybe Int +-- > } deriving (Eq, Show, Generic) +-- > instance ToForm BookSearchParams +-- +-- > type MyApi = "books" :> QueryParamForm BookSearchParams :> Get '[JSON] [Book] +-- > +-- > myApi :: Proxy MyApi +-- > myApi = Proxy +-- > +-- > getBooks :: Bool -> ClientM [Book] +-- > getBooks = client myApi +-- > -- then you can just use "getBooks" to query that endpoint. +-- > -- 'getBooksBy Nothing' for all books +-- > -- 'getBooksBy (Just $ BookSearchParams "white noise" ["DeLillo"] Nothing)' +instance (ToForm a, HasClient m api, SBoolI (FoldRequired mods)) + => HasClient m (QueryParamForm' mods a :> api) where + + type Client m (QueryParamForm' mods a :> api) = + RequiredArgument mods a -> Client m api + + -- if mparam = Nothing, we don't add it to the query string + clientWithRoute pm Proxy req mparam = + clientWithRoute pm (Proxy :: Proxy api) $ foldRequiredArgument + (Proxy :: Proxy mods) add (maybe req add) mparam + where + add :: ToForm a => a -> Request + add qForm = concatQueryString qForm req + + hoistClientMonad pm _ f cl = \arg -> + hoistClientMonad pm (Proxy :: Proxy api) f (cl arg) + + -- | Pick a 'Method' and specify where the server you want to query is. You get -- back the full `Response`. instance RunClient m => HasClient m Raw where diff --git a/servant-client-core/src/Servant/Client/Core/Request.hs b/servant-client-core/src/Servant/Client/Core/Request.hs index bdc3e3822..50d1bb9d7 100644 --- a/servant-client-core/src/Servant/Client/Core/Request.hs +++ b/servant-client-core/src/Servant/Client/Core/Request.hs @@ -17,6 +17,7 @@ module Servant.Client.Core.Request ( addHeader, appendToPath, appendToQueryString, + concatQueryString, setRequestBody, setRequestBodyLBS, ) where @@ -50,7 +51,8 @@ import Network.HTTP.Types (Header, HeaderName, HttpVersion (..), Method, QueryItem, http11, methodGet) import Servant.API - (ToHttpApiData, toEncodedUrlPiece, toHeader, SourceIO) + (ToHttpApiData, toEncodedUrlPiece, toHeader, SourceIO, + ToForm (..), toListStable) import Servant.Client.Core.Internal (mediaTypeRnf) @@ -157,11 +159,21 @@ addHeader :: ToHttpApiData a => HeaderName -> a -> Request -> Request addHeader name val req = req { requestHeaders = requestHeaders req Seq.|> (name, toHeader val)} +concatQueryString :: ToForm a + => a + -> Request + -> Request +concatQueryString form req + = let + queryEncoder = map (bimap encodeUtf8 (Just . encodeUtf8)) + querySeq = Seq.fromList . queryEncoder . toListStable . toForm $ form + in req { requestQueryString = requestQueryString req Seq.>< querySeq } + + -- | Set body and media type of the request being constructed. -- -- The body is set to the given bytestring using the 'RequestBodyLBS' -- constructor. --- -- @since 0.12 -- setRequestBodyLBS :: LBS.ByteString -> MediaType -> Request -> Request diff --git a/servant-docs/src/Servant/Docs/Internal.hs b/servant-docs/src/Servant/Docs/Internal.hs index 0b37b1d4a..9ff742d03 100644 --- a/servant-docs/src/Servant/Docs/Internal.hs +++ b/servant-docs/src/Servant/Docs/Internal.hs @@ -28,6 +28,7 @@ import Control.Lens (makeLenses, mapped, each, over, set, to, toListOf, traversed, view, _1, (%~), (&), (.~), (<>~), (^.), (|>)) import qualified Data.ByteString.Char8 as BSC +import qualified Data.ByteString.Lazy.Char8 as LBSC import Data.ByteString.Lazy.Char8 (ByteString) import qualified Data.CaseInsensitive as CI @@ -1052,6 +1053,25 @@ instance (KnownSymbol sym, ToParam (QueryFlag sym), HasDocs api) paramP = Proxy :: Proxy (QueryFlag sym) action' = over params (|> toParam paramP) action +-- | The docs for a @'QueryParamForm' a'@ +-- require a 'ToSample a' instance +instance (ToForm a, ToSample a, HasDocs api) + => HasDocs (QueryParamForm' mods a :> api) where + docsFor Proxy (endpoint, action) = + docsFor subApiP (endpoint, action') + + where subApiP = Proxy :: Proxy api + action' = + let (Just sampleForm) = toSample (Proxy :: Proxy a) + sampleEncoding = LBSC.unpack . urlEncodeAsForm . toForm $ sampleForm + in action & params <>~ [qParamMaker sampleEncoding] + qParamMaker formEncodedSample = DocQueryParam { + _paramName = "Collection of Parameters" + , _paramValues = [formEncodedSample] + , _paramDesc = "Query parameters" + , _paramKind = Normal + } + instance (ToFragment (Fragment a), HasDocs api) => HasDocs (Fragment a :> api) where diff --git a/servant-docs/test/Servant/DocsSpec.hs b/servant-docs/test/Servant/DocsSpec.hs index 4a9efaee9..9b87c1ac0 100644 --- a/servant-docs/test/Servant/DocsSpec.hs +++ b/servant-docs/test/Servant/DocsSpec.hs @@ -52,6 +52,8 @@ instance ToParam (QueryParam' mods "bar" Int) where toParam _ = DocQueryParam "bar" ["1","2","3"] "QueryParams Int" Normal instance ToParam (QueryParams "foo" Int) where toParam _ = DocQueryParam "foo" ["1","2","3"] "QueryParams Int" List +instance ToParam (QueryParam "query" String) where + toParam _ = DocQueryParam "query" ["a","b","c"] "QueryParams String" Normal instance ToParam (QueryFlag "foo") where toParam _ = DocQueryParam "foo" [] "QueryFlag" Flag instance ToCapture (Capture "foo" Int) where @@ -123,6 +125,12 @@ spec = describe "Servant.Docs" $ do md `shouldContain` "## POST" md `shouldContain` "## GET" + it "should mention the endpoints" $ do + md `shouldContain` "## POST /" + md `shouldContain` "## GET /qparam" + md `shouldContain` "## GET /qparamform" + md `shouldContain` "## PUT /" + it "mentions headers" $ do md `shouldContain` "- This endpoint is sensitive to the value of the **X-Test** HTTP header." @@ -133,6 +141,12 @@ spec = describe "Servant.Docs" $ do it "contains request body samples" $ md `shouldContain` "17" + it "mentions optional query-param" $ do + md `shouldContain` "### GET Parameters:" + md `shouldContain` "- query" + it "mentions optional query-param-form params from QueryParamForm" $ + md `shouldContain` "**Values**: *dt1field2=13&dt1field1=field%201*" + it "does not generate any docs mentioning the 'empty-api' path" $ md `shouldNotContain` "empty-api" @@ -149,6 +163,7 @@ data Datatype1 = Datatype1 { dt1field1 :: String } deriving (Eq, Show, Generic) instance ToJSON Datatype1 +instance ToForm Datatype1 instance ToSample Datatype1 where toSamples _ = singleSample $ Datatype1 "field 1" 13 @@ -166,6 +181,9 @@ type TestApi1 = Get '[JSON, PlainText] (Headers '[Header "Location" String] Int) :<|> ReqBody '[JSON] String :> Post '[JSON] Datatype1 :<|> Header "X-Test" Int :> Put '[JSON] Int :<|> "empty-api" :> EmptyAPI + :<|> "qparam" :> QueryParam "query" String :> Get '[JSON] Datatype1 + :<|> "qparamform" :> QueryParamForm Datatype1 :> Get '[JSON] Datatype1 + type TestApi2 = "duplicate-endpoint" :> Get '[JSON] Datatype1 :<|> "duplicate-endpoint" :> Get '[PlainText] Int diff --git a/servant-foreign/src/Servant/Foreign/Internal.hs b/servant-foreign/src/Servant/Foreign/Internal.hs index d77cdb84a..ed4c451f6 100644 --- a/servant-foreign/src/Servant/Foreign/Internal.hs +++ b/servant-foreign/src/Servant/Foreign/Internal.hs @@ -109,6 +109,7 @@ data ArgType = Normal | Flag | List + | Form deriving (Data, Eq, Show, Typeable) makePrisms ''ArgType @@ -416,6 +417,18 @@ instance { _argName = PathSegment str , _argType = typeFor lang ftype (Proxy :: Proxy Bool) } +instance (HasForeignType lang ftype (RequiredArgument mods a), HasForeign lang ftype api) + => HasForeign lang ftype (QueryParamForm' mods a :> api) where + type Foreign ftype (QueryParamForm' mods a :> api) = Foreign ftype api + + foreignFor lang Proxy Proxy req = + foreignFor lang (Proxy :: Proxy ftype) (Proxy :: Proxy api) $ + req & reqUrl.queryStr <>~ [QueryArg arg Form] + where + arg = Arg + { _argName = PathSegment "" + , _argType = typeFor lang (Proxy :: Proxy ftype) (Proxy :: Proxy (RequiredArgument mods a)) } + instance (HasForeignType lang ftype (Maybe a), HasForeign lang ftype api) => HasForeign lang ftype (Fragment a :> api) where @@ -426,6 +439,7 @@ instance where argT = typeFor lang (Proxy :: Proxy ftype) (Proxy :: Proxy (Maybe a)) + instance HasForeign lang ftype Raw where type Foreign ftype Raw = HTTP.Method -> Req ftype diff --git a/servant-foreign/test/Servant/ForeignSpec.hs b/servant-foreign/test/Servant/ForeignSpec.hs index 12e52401c..fe65493a7 100644 --- a/servant-foreign/test/Servant/ForeignSpec.hs +++ b/servant-foreign/test/Servant/ForeignSpec.hs @@ -66,9 +66,21 @@ instance {-# OVERLAPPABLE #-} HasForeignType LangX String a => HasForeignType La instance (HasForeignType LangX String a) => HasForeignType LangX String (Maybe a) where typeFor lang ftype _ = "maybe " <> typeFor lang ftype (Proxy :: Proxy a) +data ContactForm = ContactForm { + name :: String + , message :: String + , email :: String +} deriving (Eq, Show) + +instance HasForeignType LangX String ContactForm where + typeFor _ _ _ = "contactFormX" + + + type TestApi = "test" :> Header "header" [String] :> QueryFlag "flag" :> Get '[JSON] Int :<|> "test" :> QueryParam "param" Int :> ReqBody '[JSON] [String] :> Post '[JSON] NoContent + :<|> "test" :> QueryParamForm ContactForm :> Post '[JSON] NoContent :<|> "test" :> QueryParams "params" Int :> ReqBody '[JSON] String :> Put '[JSON] NoContent :<|> "test" :> Capture "id" Int :> Delete '[JSON] NoContent :<|> "test" :> CaptureAll "ids" Int :> Get '[JSON] [Int] @@ -80,9 +92,9 @@ testApi = listFromAPI (Proxy :: Proxy LangX) (Proxy :: Proxy String) (Proxy :: P listFromAPISpec :: Spec listFromAPISpec = describe "listFromAPI" $ do it "generates 5 endpoints for TestApi" $ do - length testApi `shouldBe` 5 + length testApi `shouldBe` 6 - let [getReq, postReq, putReq, deleteReq, captureAllReq] = testApi + let [getReq, postReq, contactReq, putReq, deleteReq, captureAllReq] = testApi it "collects all info for get request" $ do shouldBe getReq $ defReq @@ -110,6 +122,19 @@ listFromAPISpec = describe "listFromAPI" $ do , _reqFuncName = FunctionName ["post", "test"] } + it "collects all info for a queryparamform" $ do + shouldBe contactReq $ defReq + { _reqUrl = Url + [ Segment $ Static "test" ] + [ QueryArg (Arg "" "maybe contactFormX") Form ] + Nothing + , _reqMethod = "POST" + , _reqHeaders = [] + , _reqBody = Nothing + , _reqReturnType = Just "voidX" + , _reqFuncName = FunctionName ["post", "test"] + } + it "collects all info for put request" $ do shouldBe putReq $ defReq { _reqUrl = Url @@ -151,3 +176,4 @@ listFromAPISpec = describe "listFromAPI" $ do , _reqReturnType = Just "listX of intX" , _reqFuncName = FunctionName ["get", "test", "by", "ids"] } + diff --git a/servant-http-streams/test/Servant/ClientSpec.hs b/servant-http-streams/test/Servant/ClientSpec.hs index b0c752dfd..3545492fb 100644 --- a/servant-http-streams/test/Servant/ClientSpec.hs +++ b/servant-http-streams/test/Servant/ClientSpec.hs @@ -32,7 +32,7 @@ import Control.Concurrent import Control.DeepSeq (NFData (..)) import Control.Exception - (bracket, fromException, IOException) + (IOException, bracket, fromException) import Control.Monad.Error.Class (throwError) import Data.Aeson @@ -46,9 +46,9 @@ import Data.Monoid () import Data.Proxy import GHC.Generics (Generic) -import qualified Network.HTTP.Types as HTTP +import qualified Network.HTTP.Types as HTTP import Network.Socket -import qualified Network.Wai as Wai +import qualified Network.Wai as Wai import Network.Wai.Handler.Warp import Test.Hspec import Test.Hspec.QuickCheck @@ -62,9 +62,10 @@ import Servant.API BasicAuthData (..), Capture, CaptureAll, Delete, DeleteNoContent, EmptyAPI, FormUrlEncoded, Get, Header, Headers, JSON, NoContent (NoContent), Post, Put, QueryFlag, - QueryParam, QueryParams, Raw, ReqBody, addHeader, getHeaders) -import qualified Servant.Client.Core.Auth as Auth -import qualified Servant.Client.Core.Request as Req + QueryParam, QueryParamForm, QueryParams, Raw, ReqBody, + addHeader, getHeaders) +import qualified Servant.Client.Core.Auth as Auth +import qualified Servant.Client.Core.Request as Req import Servant.HttpStreams import Servant.Server import Servant.Server.Experimental.Auth @@ -119,6 +120,7 @@ type Api = :<|> "body" :> ReqBody '[FormUrlEncoded,JSON] Person :> Post '[JSON] Person :<|> "param" :> QueryParam "name" String :> Get '[FormUrlEncoded,JSON] Person :<|> "params" :> QueryParams "names" String :> Get '[JSON] [Person] + :<|> "paramform" :> QueryParamForm Person :> Get '[JSON] [Person] :<|> "flag" :> QueryFlag "flag" :> Get '[JSON] Bool :<|> "rawSuccess" :> Raw :<|> "rawFailure" :> Raw @@ -144,6 +146,7 @@ getCaptureAll :: [String] -> ClientM [Person] getBody :: Person -> ClientM Person getQueryParam :: Maybe String -> ClientM Person getQueryParams :: [String] -> ClientM [Person] +getQueryParamForm :: Maybe Person -> ClientM [Person] getQueryFlag :: Bool -> ClientM Bool getRawSuccess :: HTTP.Method -> ClientM Response getRawFailure :: HTTP.Method -> ClientM Response @@ -161,6 +164,7 @@ getRoot :<|> getBody :<|> getQueryParam :<|> getQueryParams + :<|> getQueryParamForm :<|> getQueryFlag :<|> getRawSuccess :<|> getRawFailure @@ -183,6 +187,10 @@ server = serve api ( Just n -> throwError $ ServerError 400 (n ++ " not found") "" [] Nothing -> throwError $ ServerError 400 "missing parameter" "" []) :<|> (\ names -> return (zipWith Person names [0..])) + :<|> (\ psearch -> case psearch of + Just (Right _) -> return [alice, carol] + Just (Left _) -> throwError $ ServerError 400 "failed to decode form" "" [] + Nothing -> return []) :<|> return :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.ok200 [] "rawSuccess") :<|> (Tagged $ \ _request respond -> respond $ Wai.responseLBS HTTP.badRequest400 [] "rawFailure") @@ -303,6 +311,11 @@ successSpec = beforeAll (startWaiApp server) $ afterAll endWaiApp $ do left show <$> runClient (getQueryParams ["alice", "bob"]) baseUrl `shouldReturn` Right [Person "alice" 0, Person "bob" 1] + it "Servant.API.QueryParam.QueryParamForm" $ \(_, baseUrl) -> do + left show <$> runClient (getQueryParamForm Nothing) baseUrl `shouldReturn` Right [] + left show <$> runClient (getQueryParamForm (Just $ Person "a" 10)) baseUrl + `shouldReturn` Right [alice, carol] + context "Servant.API.QueryParam.QueryFlag" $ forM_ [False, True] $ \ flag -> it (show flag) $ \(_, baseUrl) -> do left show <$> runClient (getQueryFlag flag) baseUrl `shouldReturn` Right flag diff --git a/servant-server/servant-server.cabal b/servant-server/servant-server.cabal index 86e00d311..ba564b1ac 100644 --- a/servant-server/servant-server.cabal +++ b/servant-server/servant-server.cabal @@ -141,6 +141,7 @@ test-suite spec , base-compat , base64-bytestring , bytestring + , http-api-data , http-types , mtl , resourcet diff --git a/servant-server/src/Servant/Server/Internal.hs b/servant-server/src/Servant/Server/Internal.hs index e15102e01..4331c1a12 100644 --- a/servant-server/src/Servant/Server/Internal.hs +++ b/servant-server/src/Servant/Server/Internal.hs @@ -63,7 +63,7 @@ import Network.Socket (SockAddr) import Network.Wai (Application, Request, httpVersion, isSecure, lazyRequestBody, - queryString, remoteHost, getRequestBodyChunk, requestHeaders, + queryString, rawQueryString, remoteHost, getRequestBodyChunk, requestHeaders, requestMethod, responseLBS, responseStream, vault) import Prelude () import Prelude.Compat @@ -72,7 +72,7 @@ import Servant.API CaptureAll, Description, EmptyAPI, Fragment, FramingRender (..), FramingUnrender (..), FromSourceIO (..), Header', If, IsSecure (..), NoContentVerb, QueryFlag, - QueryParam', QueryParams, Raw, ReflectMethod (reflectMethod), + QueryParam', QueryParams, QueryParamForm', Raw, ReflectMethod (reflectMethod), RemoteHost, ReqBody', SBool (..), SBoolI (..), SourceIO, Stream, StreamBody', Summary, ToSourceIO (..), Vault, Verb, WithNamedContext) @@ -85,6 +85,8 @@ import Servant.API.Modifiers unfoldRequestArgument) import Servant.API.ResponseHeaders (GetHeaders, Headers, getHeaders, getResponse) +import Web.FormUrlEncoded + (FromForm(..), urlDecodeAsForm) import qualified Servant.Types.SourceT as S import Web.HttpApiData (FromHttpApiData, parseHeader, parseQueryParam, parseUrlPiece, @@ -577,6 +579,70 @@ instance (KnownSymbol sym, HasServer api context) examine v | v == "true" || v == "1" || v == "" = True | otherwise = False +-- | If you define a custom record type, for example @BookSearchParams@, then you can use +-- @'QueryParamForm' BookSearchParams@ in one of the endpoints for your API +-- to translate a collection of query-string parameters into a value of your record type. +-- +-- Your server-side handler must be a function that takes an argument of type +-- @'Maybe' ('Either' BookSearchParams)@. +-- +-- You can control how the individual values are converted from the query string +-- into a value of your type by simply providing an instance of 'FromForm' for your type. +-- All of the record's values utilize 'FromHttpApiData'. +-- +-- Note: anytime you use a 'QueryParamForm', your server will assume it's present +-- if the query-string is non-empty. This modifier does not check if any specific +-- keys from the record are present: it just attempts to 'urlDecodeAsForm' the whole query +-- string if any query-string parameters have been provided. +-- +-- Example: +-- +-- > data BookSearchParams = BookSearchParams +-- > { title :: Text +-- > , authors :: [Text] +-- > , page :: Maybe Int +-- > } deriving (Eq, Show, Generic) +-- > instance FromForm BookSearchParams +-- > type MyApi = "books" :> QueryParamForm BookSearchParams :> Get '[JSON] [Book] +-- +-- Example Handler Signature: +-- Maybe (Either Text BookSearchParams) -> Handler [Book] +instance + (FromForm a, HasServer api context + , SBoolI (FoldRequired mods), SBoolI (FoldLenient mods) + ) + => HasServer (QueryParamForm' mods a :> api) context where +------ + type ServerT (QueryParamForm' mods a :> api) m = + RequestArgument mods a -> ServerT api m + + hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt . s + + route Proxy context subserver = + + let parseParamForm req = + unfoldRequestArgument (Proxy :: Proxy mods) errReq errSt mev + where + rawQS = rawQueryString req + + mev :: Maybe (Either T.Text a) + mev = case B.length rawQS of + 0 -> Nothing + _ -> Just $ urlDecodeAsForm $ BL.drop 1 $ BL.fromStrict rawQS + + errReq = delayedFailFatal err400 + { errBody = "Query parameter form is required" + } + + errSt e = delayedFailFatal err400 + { errBody = cs $ "Error: parsing query parameter form failed. " <> e + } + + delayed = addParameterCheck subserver . withRequest $ \req -> + parseParamForm req + + in route (Proxy :: Proxy api) context delayed + -- | Just pass the request to the underlying application and serve its response. -- -- Example: diff --git a/servant-server/test/Servant/ServerSpec.hs b/servant-server/test/Servant/ServerSpec.hs index 39e75cd4a..2dc7ad5f4 100644 --- a/servant-server/test/Servant/ServerSpec.hs +++ b/servant-server/test/Servant/ServerSpec.hs @@ -52,9 +52,10 @@ import Servant.API Delete, EmptyAPI, Fragment, Get, HasStatus (StatusOf), Header, Headers, HttpVersion, IsSecure (..), JSON, Lenient, NoContent (..), NoContentVerb, NoFraming, OctetStream, Patch, - PlainText, Post, Put, QueryFlag, QueryParam, QueryParams, Raw, + PlainText, Post, Put, QueryFlag, QueryParam, QueryParams, QueryParamForm, Raw, RemoteHost, ReqBody, SourceIO, StdMethod (..), Stream, Strict, UVerb, Union, Verb, WithStatus (..), addHeader) + import Servant.Server (Context ((:.), EmptyContext), Handler, Server, Tagged (..), emptyServer, err401, err403, err404, respond, serve, @@ -67,6 +68,8 @@ import Test.Hspec.Wai (get, liftIO, matchHeaders, matchStatus, shouldRespondWith, with, (<:>)) import qualified Test.Hspec.Wai as THW +import Web.FormUrlEncoded + (FromForm) import Servant.Server.Experimental.Auth (AuthHandler, AuthServerData, mkAuthHandler) @@ -93,6 +96,7 @@ spec = do captureSpec captureAllSpec queryParamSpec + queryParamFormSpec fragmentSpec reqBodySpec headerSpec @@ -464,6 +468,133 @@ queryParamSpec = do { name = "Alice" } +------------------------------------------------------------------------------ +-- * queryParamFormSpec {{{ +------------------------------------------------------------------------------ + +data AnimalSearch = AnimalSearch { + sName :: String + , sLegs :: Integer +} deriving (Eq, Show, Generic) + +instance FromForm AnimalSearch + +type QueryParamFormApi = + QueryParamForm AnimalSearch :> Get '[JSON] Animal + :<|> "before-param" + :> QueryParam "before" Bool + :> QueryParamForm AnimalSearch + :> Get '[JSON] Animal + :<|> "mixed-param" + :> QueryParam "before" Bool + :> QueryParamForm AnimalSearch + :> QueryParam "after" Bool + :> Get '[JSON] Animal + +queryParamFormApi :: Proxy QueryParamFormApi +queryParamFormApi = Proxy + +qpFormServer :: Server QueryParamFormApi +qpFormServer = searchAnimal :<|> searchWithBeforeParms :<|> searchWithAroundParms + + where searchAnimal (Just (Right search)) = return $ Animal { species = sName search, numberOfLegs = sLegs search} + searchAnimal (Just (Left _)) = return $ Animal { species = "broken", numberOfLegs = 0} + searchAnimal Nothing = return bimac + + searchWithBeforeParms (Just _) (Just (Right search)) = return $ Animal { species = sName search, numberOfLegs = sLegs search} + searchWithBeforeParms _ _ = return bimac + + searchWithAroundParms (Just _) (Just (Right search)) (Just True) = return $ Animal { species = sName search, numberOfLegs = sLegs search} + searchWithAroundParms _ _ _ = return bimac + + +queryParamFormSpec :: Spec +queryParamFormSpec = do + describe "Servant.API.QueryParamForm" $ do + it "allows query params into form" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let params1 = "?sName=bimac&sLegs=7" + response1 <- Network.Wai.Test.request defaultRequest{ + rawQueryString = params1, + queryString = parseQuery params1 + } + liftIO $ do + decode' (simpleBody response1) `shouldBe` (Just $ Animal { species = "bimac", numberOfLegs = 7}) + it "Just a question mark will match but return a Left" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let paramsQ = "?" + response1 <- Network.Wai.Test.request defaultRequest{ + rawQueryString = paramsQ, + queryString = parseQuery paramsQ + } + liftIO $ do + decode' (simpleBody response1) `shouldBe` (Just $ Animal { species = "broken", numberOfLegs = 0}) + it "allows no query params at all" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + response1 <- Network.Wai.Test.request defaultRequest + liftIO $ do + decode' (simpleBody response1) `shouldBe` Just bimac + it "does not generate an error for incomplete form" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let paramsBork = "?sName=bimac" + responseBork <- Network.Wai.Test.request defaultRequest{ + rawQueryString = paramsBork, + queryString = parseQuery paramsBork + } + liftIO $ do + decode' (simpleBody responseBork) `shouldBe` (Just $ Animal { species = "broken", numberOfLegs = 0}) + return () + it "does not generate an error for duplicated keys" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let paramsBork = "?sName=bimac&sName=dup&sLegs=12" + responseBork <- Network.Wai.Test.request defaultRequest{ + rawQueryString = paramsBork, + queryString = parseQuery paramsBork + } + liftIO $ do + decode' (simpleBody responseBork) `shouldBe` (Just $ Animal { species = "broken", numberOfLegs = 0}) + return () + it "does not generate an error for form with bad input types" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let paramsBork = "?sName=bimac&sLegs=ocean" + responseBork <- Network.Wai.Test.request defaultRequest{ + rawQueryString = paramsBork, + queryString = parseQuery paramsBork + } + liftIO $ do + decode' (simpleBody responseBork) `shouldBe` (Just $ Animal { species = "broken", numberOfLegs = 0}) + return () + + it "allows query params into form even with other params" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let params1 = "?before=true&sName=bimac&sLegs=6" + response1 <- Network.Wai.Test.request defaultRequest{ + rawQueryString = params1, + queryString = parseQuery params1, + pathInfo = ["before-param"] + } + liftIO $ do + decode' (simpleBody response1) `shouldBe` (Just $ Animal { species = "bimac", numberOfLegs = 6}) + + let params2 = "?sName=bimac&before=true&sLegs=5" + response2 <- Network.Wai.Test.request defaultRequest{ + rawQueryString = params2, + queryString = parseQuery params2, + pathInfo = ["before-param"] + } + liftIO $ do + decode' (simpleBody response2) `shouldBe` (Just $ Animal { species = "bimac", numberOfLegs = 5}) + it "allows completely mixed up params with QueryParamForm" $ + (flip runSession) (serve queryParamFormApi qpFormServer) $ do + let params1 = "?sLegs=1&before=true&sName=bimac&after=true&unknown=ignoreThis" + response1 <- Network.Wai.Test.request defaultRequest{ + rawQueryString = params1, + queryString = parseQuery params1, + pathInfo = ["mixed-param"] + } + liftIO $ do + decode' (simpleBody response1) `shouldBe` (Just $ Animal { species = "bimac", numberOfLegs = 1}) + -- }}} ------------------------------------------------------------------------------ -- * fragmentSpec {{{ @@ -947,4 +1078,8 @@ chimera = Animal "Chimera" (-1) beholder :: Animal beholder = Animal "Beholder" 0 + +bimac :: Animal +bimac = Animal { species = "Octopus bimaculoides" , numberOfLegs = 8} + -- }}} diff --git a/servant/servant.cabal b/servant/servant.cabal index 41ea57923..8b6fe355b 100644 --- a/servant/servant.cabal +++ b/servant/servant.cabal @@ -154,6 +154,7 @@ test-suite spec , base-compat , aeson , bytestring + , http-api-data , http-media , mtl , servant diff --git a/servant/src/Servant/API.hs b/servant/src/Servant/API.hs index deb974ae7..134624c8f 100644 --- a/servant/src/Servant/API.hs +++ b/servant/src/Servant/API.hs @@ -61,6 +61,9 @@ module Servant.API ( module Web.HttpApiData, -- | Classes and instances for types that can be converted to and from HTTP API data. + -- * ToForm and FromForm + module Web.FormUrlEncoded, + -- | Classes and instances for working with Forms -- * Experimental modules module Servant.API.Experimental.Auth, @@ -106,7 +109,8 @@ import Servant.API.IsSecure import Servant.API.Modifiers (Lenient, Optional, Required, Strict) import Servant.API.QueryParam - (QueryFlag, QueryParam, QueryParam', QueryParams) + (QueryFlag, QueryParam, QueryParam', QueryParams, + QueryParamForm, QueryParamForm') import Servant.API.Raw (Raw) import Servant.API.RemoteHost @@ -145,3 +149,5 @@ import Servant.Links (HasLink (..), IsElem, IsElem', Link, URI (..), safeLink) import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..)) +import Web.FormUrlEncoded + (ToForm (..), urlEncodeAsForm, toListStable) \ No newline at end of file diff --git a/servant/src/Servant/API/QueryParam.hs b/servant/src/Servant/API/QueryParam.hs index 45d0e7ee3..adb5d35ac 100644 --- a/servant/src/Servant/API/QueryParam.hs +++ b/servant/src/Servant/API/QueryParam.hs @@ -3,7 +3,7 @@ {-# LANGUAGE PolyKinds #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_HADDOCK not-home #-} -module Servant.API.QueryParam (QueryFlag, QueryParam, QueryParam', QueryParams) where +module Servant.API.QueryParam (QueryFlag, QueryParam, QueryParam', QueryParams, QueryParamForm, QueryParamForm') where import Data.Typeable (Typeable) @@ -38,6 +38,39 @@ data QueryParam' (mods :: [*]) (sym :: Symbol) (a :: *) data QueryParams (sym :: Symbol) (a :: *) deriving Typeable +-- | Lookup the values associated with a collection of query string parameters +-- and try to extract them as a value of type @a@. This is typically +-- meant to query string parameters of the form +-- @param1=val1¶m2=val2@ and so on into a custom type represented by the form. +-- +-- Note: Unlike with 'QueryParam', by default 'QueryParamForm' is parsed in a +-- 'Lenient' way, because it's difficult to know if it should be parsed +-- or not (when other 'QueryParam's are present). As a result, most users +-- of 'QueryParamForm' are going to implement handlers that take a value +-- of type (Maybe (Either Text a)). This also means that in a server implementation +-- if there as a query string of any length (even just a "?"), we'll try to parse +-- the 'QueryParamForm' into the custom type specified. +-- +-- Example: +-- +-- > data BookSearchParams = BookSearchParams +-- > { title :: Text +-- > , authors :: [Text] +-- > , page :: Maybe Int +-- > } deriving (Eq, Show, Generic) +-- > instance FromForm BookSearchParams +-- > type MyApi = "books" :> QueryParamForm BookSearchParams :> Get '[JSON] [Book] +-- +-- Example Handler Signature: +-- Maybe (Either Text BookSearchParams) -> Handler [Book] + +type QueryParamForm = QueryParamForm' '[Optional, Lenient] + +-- | 'QueryParamForm' which can be 'Required', 'Lenient', or modified otherwise. +data QueryParamForm' (mods :: [*]) (a :: *) + deriving Typeable + + -- | Lookup a potentially value-less query string parameter -- with boolean semantics. If the param @sym@ is there without any value, -- or if it's there with value "true" or "1", it's interpreted as 'True'. diff --git a/servant/src/Servant/API/TypeLevel.hs b/servant/src/Servant/API/TypeLevel.hs index 4a5e3c3b9..14ecdee1f 100644 --- a/servant/src/Servant/API/TypeLevel.hs +++ b/servant/src/Servant/API/TypeLevel.hs @@ -61,7 +61,7 @@ import Servant.API.Fragment import Servant.API.Header (Header) import Servant.API.QueryParam - (QueryFlag, QueryParam, QueryParams) + (QueryFlag, QueryParam, QueryParams, QueryParamForm) import Servant.API.ReqBody (ReqBody) import Servant.API.Sub @@ -137,6 +137,7 @@ type family IsElem endpoint api :: Constraint where = IsElem sa sb IsElem sa (QueryParam x y :> sb) = IsElem sa sb IsElem sa (QueryParams x y :> sb) = IsElem sa sb + IsElem sa (QueryParamForm x :> sb) = IsElem sa sb IsElem sa (QueryFlag x :> sb) = IsElem sa sb IsElem sa (Fragment x :> sb) = IsElem sa sb IsElem (Verb m s ct typ) (Verb m s ct' typ) diff --git a/servant/src/Servant/Links.hs b/servant/src/Servant/Links.hs index 50a7ee57a..5c4e84a55 100644 --- a/servant/src/Servant/Links.hs +++ b/servant/src/Servant/Links.hs @@ -124,6 +124,8 @@ module Servant.Links ( , linkFragment ) where +import qualified Data.ByteString.Lazy as LBS +import qualified Data.ByteString.Lazy.Char8 as LBSC import Data.List import Data.Proxy (Proxy (..)) @@ -139,6 +141,8 @@ import Network.URI (URI (..), escapeURIString, isUnreserved) import Prelude () import Prelude.Compat +import Web.FormUrlEncoded + (ToForm(..), urlEncodeAsForm) import Servant.API.Alternative ((:<|>) ((:<|>))) @@ -164,7 +168,7 @@ import Servant.API.IsSecure import Servant.API.Modifiers (FoldRequired) import Servant.API.QueryParam - (QueryFlag, QueryParam', QueryParams) + (QueryFlag, QueryParam', QueryParams, QueryParamForm') import Servant.API.Raw (Raw) import Servant.API.RemoteHost @@ -228,6 +232,7 @@ data Param = SingleParam String Text.Text | ArrayElemParam String Text.Text | FlagParam String + | FormParam LBS.ByteString deriving Show addSegment :: Escaped -> Link -> Link @@ -297,6 +302,7 @@ linkURI' addBrackets (Link segments q_params mfragment) = makeQuery (ArrayElemParam k v) = escape k <> style <> escape (Text.unpack v) makeQuery (SingleParam k v) = escape k <> "=" <> escape (Text.unpack v) makeQuery (FlagParam k) = escape k + makeQuery (FormParam f) = LBSC.unpack f makeFragment :: Fragment' -> String makeFragment Nothing = "" @@ -489,6 +495,16 @@ instance (KnownSymbol sym, HasLink sub) where k = symbolVal (Proxy :: Proxy sym) +instance (ToForm v, HasLink sub, SBoolI (FoldRequired mods)) + => HasLink (QueryParamForm' mods v :> sub) + where + type MkLink (QueryParamForm' mods v :> sub) a = If (FoldRequired mods) v (Maybe v) -> MkLink sub a + toLink toA _ l mv = + toLink toA (Proxy :: Proxy sub) $ + case sbool :: SBool (FoldRequired mods) of + STrue -> (addQueryParam . FormParam . urlEncodeAsForm) mv l + SFalse -> maybe id (addQueryParam . FormParam . urlEncodeAsForm) mv l + -- :<|> instance - Generate all links at once instance (HasLink a, HasLink b) => HasLink (a :<|> b) where type MkLink (a :<|> b) r = MkLink a r :<|> MkLink b r diff --git a/servant/test/Servant/LinksSpec.hs b/servant/test/Servant/LinksSpec.hs index 55c682286..d1f0820e1 100644 --- a/servant/test/Servant/LinksSpec.hs +++ b/servant/test/Servant/LinksSpec.hs @@ -1,16 +1,20 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE PolyKinds #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeOperators #-} module Servant.LinksSpec where + import Data.Proxy (Proxy (..)) import Data.String (fromString) +import qualified Data.Text as T +import GHC.Generics import Test.Hspec - (Expectation, Spec, describe, it, shouldBe) + (Expectation, Spec, describe, it, shouldBe, shouldContain) import Servant.API import Servant.Links @@ -21,6 +25,8 @@ type TestApi = -- Capture and query params "hello" :> Capture "name" String :> QueryParam "capital" Bool :> Delete '[JSON] NoContent :<|> "hi" :> Capture "name" String :> QueryParam' '[Required] "capital" Bool :> Delete '[JSON] NoContent + :<|> "formR" :> QueryParamForm' '[Required, Strict] TestForm :> Delete '[JSON] NoContent + :<|> "form-opt" :> QueryParamForm TestForm :> Delete '[JSON] NoContent :<|> "all" :> CaptureAll "names" String :> Get '[JSON] NoContent -- Flags @@ -49,12 +55,23 @@ apiLink :: (IsElem endpoint TestApi, HasLink endpoint) => Proxy endpoint -> MkLink endpoint Link apiLink = safeLink (Proxy :: Proxy TestApi) +data TestForm = TestForm { + testing :: String + , time :: String +} deriving (Eq, Generic) + +instance ToForm TestForm + -- | Convert a link to a URI and ensure that this maps to the given string -- given string shouldBeLink :: Link -> String -> Expectation shouldBeLink link expected = toUrlPiece link `shouldBe` fromString expected +linkShouldContain :: Link -> String -> Expectation +linkShouldContain link expected = + T.unpack (toUrlPiece link) `shouldContain` expected + spec :: Spec spec = describe "Servant.Links" $ do it "generates correct links for capture query params" $ do @@ -71,6 +88,28 @@ spec = describe "Servant.Links" $ do :> Delete '[JSON] NoContent) apiLink l4 "privet" False `shouldBeLink` "hi/privet?capital=false" + it "generates query param form links" $ do + -- most who use QueryParamForm are not going to use it Required, Strict, so we'll test it both ways + let l3 = Proxy :: Proxy ("formR" :> QueryParamForm' '[Required, Strict] TestForm + :> Delete '[JSON] NoContent) + + let result3 = apiLink l3 (TestForm "sure" "später") + -- we can't guarantee the order of the params unless we switch to `urlEncodeAsFormStable`... + result3 `linkShouldContain` "formR?" + result3 `linkShouldContain` "&" + result3 `linkShouldContain` "time=sp%C3%A4ter" + result3 `linkShouldContain` "testing=sure" + + let l4 = Proxy :: Proxy ("form-opt" :> QueryParamForm TestForm + :> Delete '[JSON] NoContent) + + let result4 = apiLink l4 (Just $ TestForm "sure" "später") + -- we can't guarantee the order of the params unless we switch to `urlEncodeAsFormStable`... + result4 `linkShouldContain` "form-opt?" + result4 `linkShouldContain` "&" + result4 `linkShouldContain` "time=sp%C3%A4ter" + result4 `linkShouldContain` "testing=sure" + it "generates correct links for CaptureAll" $ do apiLink (Proxy :: Proxy ("all" :> CaptureAll "names" String :> Get '[JSON] NoContent)) ["roads", "lead", "to", "rome"]