diff --git a/servant-server/servant-server.cabal b/servant-server/servant-server.cabal index fe3496a74..999111bb8 100644 --- a/servant-server/servant-server.cabal +++ b/servant-server/servant-server.cabal @@ -127,6 +127,7 @@ test-suite spec , exceptions , hspec == 2.* , hspec-wai >= 0.8 && <0.9 + , http-api-data , http-types , mtl , network >= 2.6 diff --git a/servant-server/src/Servant/Server/Internal.hs b/servant-server/src/Servant/Server/Internal.hs index 686cf59d5..73bdfd756 100644 --- a/servant-server/src/Servant/Server/Internal.hs +++ b/servant-server/src/Servant/Server/Internal.hs @@ -46,6 +46,7 @@ import Network.Wai (Application, Request, Response, responseLBS, vault) import Prelude () import Prelude.Compat +import Web.FormUrlEncoded (FromForm, urlDecodeAsForm) import Web.HttpApiData (FromHttpApiData, parseHeaderMaybe, parseQueryParam, parseUrlPieceMaybe, @@ -54,7 +55,7 @@ import Servant.API ((:<|>) (..), (:>), BasicAuth, Capt CaptureAll, Verb, ReflectMethod(reflectMethod), IsSecure(..), Header, QueryFlag, - QueryParam, QueryParams, Raw, + QueryParam, QueryParams, QueryParamForm, Raw, RemoteHost, ReqBody, Vault, WithNamedContext) import Servant.API.ContentTypes (AcceptHeader (..), @@ -405,6 +406,48 @@ instance (KnownSymbol sym, HasServer api context) examine v | v == "true" || v == "1" || v == "" = True | otherwise = False +-- | If you use @'QueryParamForm' BookSearchParams@ in one of the endpoints for your API, +-- this automatically requires your server-side handler to be a function +-- that takes an argument of type @['BookSearchParams']@. +-- +-- This lets servant worry about all key-values in the query string +-- and turning each of them into a value of the type you specify. +-- +-- You can control how the individual values are converted from 'BookSearchParams' +-- to your type by simply providing an instance of 'FromForm' for your type. +-- +-- Example: +-- +-- > data BookSearchParams = BookSearchParams +-- > { title :: Text +-- > { authors :: [Text] +-- > , page :: Maybe Int +-- > } deriving (Generic) +-- > instance FromForm BookSearchParams +-- > +-- > type MyApi = "books" :> QueryParamForm BookSearchParams :> Get '[JSON] [Book] +-- > +-- > server :: Server MyApi +-- > server = getBooksBy +-- > where getBooksBy :: BookSearchParams -> Handler [Book] +-- > getBooksBy searchParams = ...return all books by these conditions... + +instance (FromForm a, HasServer api context) + => HasServer (QueryParamForm a :> api) context where + + type ServerT (QueryParamForm a :> api) m = + a -> ServerT api m + + route Proxy context subserver = route (Proxy :: Proxy api) context $ + subserver `addParameterCheck` withRequest paramsCheck + where + paramsCheck req = + case urlDecodeAsForm (BL.drop 1 . BL.fromStrict $ rawQueryString req) of + Right form -> return form + Left err -> delayedFailFatal err400 + { errBody = cs $ "Error parsing query parameter(s) to form failed: " <> err + } + -- | 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 c0042f441..d6357962c 100644 --- a/servant-server/test/Servant/ServerSpec.hs +++ b/servant-server/test/Servant/ServerSpec.hs @@ -44,7 +44,7 @@ import Servant.API ((:<|>) (..), (:>), AuthProtect, NoContent (..), Patch, PlainText, Post, Put, QueryFlag, QueryParam, QueryParams, - Raw, RemoteHost, ReqBody, + QueryParamForm, Raw, RemoteHost, ReqBody, StdMethod (..), Verb, addHeader) import Servant.API.Internal.Test.ComprehensiveAPI import Servant.Server (Server, Handler, err401, err403, @@ -64,6 +64,7 @@ import Servant.Server.Experimental.Auth mkAuthHandler) import Servant.Server.Internal.Context (NamedContext(..)) +import Web.FormUrlEncoded (FromForm) -- * comprehensive api test @@ -277,12 +278,13 @@ type QueryParamApi = QueryParam "name" String :> Get '[JSON] Person :<|> "b" :> QueryFlag "capitalize" :> Get '[JSON] Person :<|> "param" :> QueryParam "age" Integer :> Get '[JSON] Person :<|> "multiparam" :> QueryParams "ages" Integer :> Get '[JSON] Person + :<|> "paramform" :> QueryParamForm Person :> Get '[JSON] Person queryParamApi :: Proxy QueryParamApi queryParamApi = Proxy qpServer :: Server QueryParamApi -qpServer = queryParamServer :<|> qpNames :<|> qpCapitalize :<|> qpAge :<|> qpAges +qpServer = queryParamServer :<|> qpNames :<|> qpCapitalize :<|> qpAge :<|> qpAges :<|> qpPerson where qpNames (_:name2:_) = return alice { name = name2 } qpNames _ = return alice @@ -295,6 +297,8 @@ qpServer = queryParamServer :<|> qpNames :<|> qpCapitalize :<|> qpAge :<|> qpAge qpAges ages = return alice{ age = sum ages} + qpPerson person = return person + queryParamServer (Just name_) = return alice{name = name_} queryParamServer Nothing = return alice @@ -410,6 +414,28 @@ queryParamSpec = do name = "Alice" } + it "parses query form" $ + (flip runSession) (serve queryParamApi qpServer) $ do + let params = "?name=Alice&age=42" + response <- Network.Wai.Test.request defaultRequest{ + rawQueryString = params, + queryString = parseQuery params, + pathInfo = ["paramform"] + } + liftIO $ + decode' (simpleBody response) `shouldBe` Just alice + + it "generates an error on parse failures of query form" $ + (flip runSession) (serve queryParamApi qpServer) $ do + let params = "?name=Alice" + response <- Network.Wai.Test.request defaultRequest{ + rawQueryString = params, + queryString = parseQuery params, + pathInfo = ["paramform"] + } + liftIO $ statusCode (simpleStatus response) `shouldBe` 400 + return () + -- }}} ------------------------------------------------------------------------------ -- * reqBodySpec {{{ @@ -732,6 +758,7 @@ data Person = Person { instance ToJSON Person instance FromJSON Person +instance FromForm Person alice :: Person alice = Person "Alice" 42 diff --git a/servant/src/Servant/API.hs b/servant/src/Servant/API.hs index 085941378..075c3c137 100644 --- a/servant/src/Servant/API.hs +++ b/servant/src/Servant/API.hs @@ -71,7 +71,7 @@ import Servant.API.Header (Header (..)) import Servant.API.HttpVersion (HttpVersion (..)) import Servant.API.IsSecure (IsSecure (..)) import Servant.API.QueryParam (QueryFlag, QueryParam, - QueryParams) + QueryParams, QueryParamForm) import Servant.API.Raw (Raw) import Servant.API.RemoteHost (RemoteHost) import Servant.API.ReqBody (ReqBody) diff --git a/servant/src/Servant/API/QueryParam.hs b/servant/src/Servant/API/QueryParam.hs index ca913e173..531528fd0 100644 --- a/servant/src/Servant/API/QueryParam.hs +++ b/servant/src/Servant/API/QueryParam.hs @@ -3,7 +3,7 @@ {-# LANGUAGE TypeOperators #-} {-# LANGUAGE PolyKinds #-} {-# OPTIONS_HADDOCK not-home #-} -module Servant.API.QueryParam (QueryFlag, QueryParam, QueryParams) where +module Servant.API.QueryParam (QueryFlag, QueryParam, QueryParams, QueryParamForm) where import Data.Typeable (Typeable) import GHC.TypeLits (Symbol) @@ -42,9 +42,21 @@ data QueryParams (sym :: Symbol) a -- >>> type MyApi = "books" :> QueryFlag "published" :> Get '[JSON] [Book] data QueryFlag (sym :: Symbol) +-- | Lookup the values associated to the query string parameter +-- and try to extract it as a value of type @a@. +-- +-- Example: +-- +-- >>> -- /books?title=&authors[]=<author1>&authors[]=<author2>&... +-- >>> type MyApi = "books" :> QueryParamForm BookSearchParams :> Get '[JSON] [Book] +data QueryParamForm a + deriving Typeable + -- $setup -- >>> import Servant.API -- >>> import Data.Aeson -- >>> import Data.Text +-- >>> import Web.FormUrlEncoded (FromForm) -- >>> data Book -- >>> instance ToJSON Book where { toJSON = undefined } +-- >>> data BookSearchParams diff --git a/servant/src/Servant/API/TypeLevel.hs b/servant/src/Servant/API/TypeLevel.hs index 3cb8076bb..90a7afcaa 100644 --- a/servant/src/Servant/API/TypeLevel.hs +++ b/servant/src/Servant/API/TypeLevel.hs @@ -51,7 +51,8 @@ import GHC.Exts (Constraint) import Servant.API.Alternative (type (:<|>)) import Servant.API.Capture (Capture, CaptureAll) import Servant.API.Header (Header) -import Servant.API.QueryParam (QueryFlag, QueryParam, QueryParams) +import Servant.API.QueryParam (QueryFlag, QueryParam, + QueryParams, QueryParamForm) import Servant.API.ReqBody (ReqBody) import Servant.API.Sub (type (:>)) import Servant.API.Verbs (Verb) @@ -123,6 +124,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 (Verb m s ct typ) (Verb m s ct' typ) = IsSubList ct ct'