Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QueryParamForm Combinator #729

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions servant-server/servant-server.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion servant-server/src/Servant/Server/Internal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (..),
Expand Down Expand Up @@ -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']@.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of type @'BookSearchParams'@, right?

--
-- 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably mention that we default implementation uses FromHttpApiData for each field.

-- >
-- > 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess BL.drop 1 is about dropping leading ??
Can rawQueryString contain extra info (e.g. HTML anchors?), would it ruin parsing?

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:
Expand Down
31 changes: 29 additions & 2 deletions servant-server/test/Servant/ServerSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -64,6 +64,7 @@ import Servant.Server.Experimental.Auth
mkAuthHandler)
import Servant.Server.Internal.Context
(NamedContext(..))
import Web.FormUrlEncoded (FromForm)

-- * comprehensive api test

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 {{{
Expand Down Expand Up @@ -732,6 +758,7 @@ data Person = Person {

instance ToJSON Person
instance FromJSON Person
instance FromForm Person

alice :: Person
alice = Person "Alice" 42
Expand Down
2 changes: 1 addition & 1 deletion servant/src/Servant/API.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion servant/src/Servant/API/QueryParam.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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=<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
4 changes: 3 additions & 1 deletion servant/src/Servant/API/TypeLevel.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'
Expand Down