{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PolyKinds #-} {-# LANGUAGE Rank2Types #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} #if MIN_VERSION_base(4,9,0) {-# OPTIONS_GHC -fno-warn-redundant-constraints #-} #endif -- #include "overlapping-compat.h" -- | This module provides 'client' which can automatically generate -- querying functions for each endpoint just from the type representing your -- API. module Servant.Reflex ( client , clientWithOpts , clientWithOptsAndResultHandler , clientWithRoute , clientWithRouteAndResultHandler , BuildHeaderKeysTo(..) , toHeaders , HasClient , Client , module Servant.Common.Req , module Servant.Common.BaseUrl ) where ------------------------------------------------------------------------------ import Control.Applicative import qualified Data.CaseInsensitive as CI import Data.Functor.Identity import qualified Data.Map as Map import Data.Monoid ((<>)) import Data.Proxy (Proxy (..)) import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as E import GHC.Exts (Constraint) import GHC.TypeLits (KnownSymbol, symbolVal) import Servant.API ((:<|>) (..), (:>), BasicAuth, BasicAuthData, BuildHeadersTo (..), Capture, Header, Headers (..), HttpVersion, IsSecure, MimeRender (..), MimeUnrender, NoContent, QueryFlag, QueryParam, QueryParams, Raw, ReflectMethod (..), RemoteHost, ReqBody, ToHttpApiData (..), Vault, Verb, contentType) import Servant.API.Description (Summary) import qualified Servant.Auth as Auth import Reflex.Dom.Core (Dynamic, Event, Reflex, XhrRequest (..), XhrResponse (..), XhrResponseHeaders (..), attachPromptlyDynWith, constDyn, ffor, fmapMaybe, leftmost, performRequestsAsync) ------------------------------------------------------------------------------ import Servant.Common.BaseUrl (BaseUrl(..), Scheme(..), baseUrlWidget, showBaseUrl, SupportsServantReflex) import Servant.Common.Req (ClientOptions(..), defaultClientOptions, Req, ReqResult(..), QParam(..), QueryPart(..), addHeader, authData, defReq, evalResponse, prependToPathParts, -- performRequestCT, performRequestsCT, -- performRequestNoBody, performRequestsNoBody, performSomeRequestsAsync, qParamToQueryPart, reqBody, reqSuccess, reqFailure, reqMethod, respHeaders, response, reqTag, qParams, withCredentials) -- * Accessing APIs as a Client -- | 'client' allows you to produce operations to query an API from a client. -- -- > type MyApi = "books" :> Get '[JSON] [Book] -- GET /books -- > :<|> "books" :> ReqBody '[JSON] Book :> Post '[JSON] Book -- POST /books -- > -- > myApi :: Proxy MyApi -- > myApi = Proxy -- > -- > getAllBooks :: Event t l -> m (Event t (l, ReqResult [Book])) -- > postNewBook :: Dynamic t (Maybe Book) -> Event t l -- > -> m (Event t (l, ReqResult Book))) -- > (getAllBooks :<|> postNewBook) = client myApi host -- > where host = constDyn $ BaseUrl Http "localhost" 8080 client :: (HasClient t m layout tag) => Proxy layout -> Proxy m -> Proxy tag -> Dynamic t BaseUrl -> Client t m layout tag client p q t baseurl = clientWithRoute p q t defReq baseurl defaultClientOptions clientWithOpts :: (HasClient t m layout tag) => Proxy layout -> Proxy m -> Proxy tag -> Dynamic t BaseUrl -> ClientOptions -> Client t m layout tag clientWithOpts p q t baseurl = clientWithRoute p q t defReq baseurl -- | Like 'clientWithOpts' but allows passing a function which will process the -- result event in some way. This can be used to handle errors in a uniform way -- across call sites. clientWithOptsAndResultHandler :: (HasClient t m layout tag) => Proxy layout -> Proxy m -> Proxy tag -> Dynamic t BaseUrl -> ClientOptions -> (forall a. Event t (ReqResult tag a) -> m (Event t (ReqResult tag a))) -> Client t m layout tag clientWithOptsAndResultHandler p q t = clientWithRouteAndResultHandler p q t defReq -- | This class lets us define how each API combinator -- influences the creation of an HTTP request. It's mostly -- an internal class, you can just use 'client'. class Monad m => HasClient t m layout (tag :: *) where type Client t m layout tag :: * clientWithRoute :: Proxy layout -> Proxy m -> Proxy tag -> Req t -> Dynamic t BaseUrl -> ClientOptions -> Client t m layout tag clientWithRoute l m t r b o = clientWithRouteAndResultHandler l m t r b o return clientWithRouteAndResultHandler :: Proxy layout -> Proxy m -> Proxy tag -> Req t -> Dynamic t BaseUrl -> ClientOptions -> (forall a. Event t (ReqResult tag a) -> m (Event t (ReqResult tag a))) -> Client t m layout tag instance (HasClient t m a tag, HasClient t m b tag) => HasClient t m (a :<|> b) tag where type Client t m (a :<|> b) tag = Client t m a tag :<|> Client t m b tag clientWithRouteAndResultHandler Proxy q pTag req baseurl opts wrap = clientWithRouteAndResultHandler (Proxy :: Proxy a) q pTag req baseurl opts wrap :<|> clientWithRouteAndResultHandler (Proxy :: Proxy b) q pTag req baseurl opts wrap -- Capture. Example: -- > type MyApi = "books" :> Capture "isbn" Text :> Get '[JSON] Book -- > -- > myApi :: Proxy MyApi = Proxy -- > -- > getBook :: SupportsServantReflex t m -- => Dynamic t BaseUrl -- -> Dynamic t (Maybe Text) -- -> Event t l -- -> m (Event t (l, ReqResult Book)) -- > getBook = client myApi (constDyn host) instance (SupportsServantReflex t m, ToHttpApiData a, HasClient t m sublayout tag) => HasClient t m (Capture capture a :> sublayout) tag where type Client t m (Capture capture a :> sublayout) tag = Dynamic t (Either Text a) -> Client t m sublayout tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap val = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t (prependToPathParts p req) baseurl opts wrap where p = (fmap . fmap) (toUrlPiece) val -- VERB (Returning content) -- instance {-# OVERLAPPABLE #-} -- Note [Non-Empty Content Types] (MimeUnrender ct a, ReflectMethod method, cts' ~ (ct ': cts), SupportsServantReflex t m ) => HasClient t m (Verb method status cts' a) tag where type Client t m (Verb method status cts' a) tag = Event t tag -> m (Event t (ReqResult tag a)) -- TODO how to access input types here? -- ExceptT ServantError IO a clientWithRouteAndResultHandler Proxy _ _ req baseurl opts wrap trigs = wrap =<< fmap runIdentity <$> performRequestsCT (Proxy :: Proxy ct) method (constDyn $ Identity $ req') baseurl opts trigs where method = E.decodeUtf8 $ reflectMethod (Proxy :: Proxy method) req' = req { reqMethod = method } -- -- VERB (No content) -- instance {-# OVERLAPPING #-} (ReflectMethod method, SupportsServantReflex t m) => HasClient t m (Verb method status cts NoContent) tag where type Client t m (Verb method status cts NoContent) tag = Event t tag -> m (Event t (ReqResult tag NoContent)) -- TODO: how to access input types here? -- ExceptT ServantError IO NoContent clientWithRouteAndResultHandler Proxy _ _ req baseurl opts wrap trigs = wrap =<< fmap runIdentity <$> performRequestsNoBody method (constDyn $ Identity req) baseurl opts trigs where method = E.decodeUtf8 $ reflectMethod (Proxy :: Proxy method) toHeaders :: BuildHeadersTo ls => ReqResult tag a -> ReqResult tag (Headers ls a) toHeaders r = let hdrs = maybe [] (\xhr -> fmap (\(h,v) -> (CI.map E.encodeUtf8 h, E.encodeUtf8 v)) (Map.toList $ _xhrResponse_headers xhr)) (response r) in ffor r $ \a -> Headers {getResponse = a ,getHeadersHList = buildHeadersTo hdrs} class BuildHeaderKeysTo hs where buildHeaderKeysTo :: Proxy hs -> [CI.CI T.Text] instance {-# OVERLAPPABLE #-} BuildHeaderKeysTo '[] where buildHeaderKeysTo _ = [] instance {-# OVERLAPPABLE #-} (BuildHeaderKeysTo xs, KnownSymbol h) => BuildHeaderKeysTo ((Header h v) ': xs) where buildHeaderKeysTo _ = let thisKey = CI.mk $ T.pack (symbolVal (Proxy :: Proxy h)) in thisKey : buildHeaderKeysTo (Proxy :: Proxy xs) -- HEADERS Verb (Content) -- -- Headers combinator not treated in fully general case, -- in order to deny instances for (Headers ls (Capture "id" Int)), -- a combinator that wouldn't make sense -- TODO Overlapping?? instance {-# OVERLAPPABLE #-} -- Note [Non-Empty Content Types] ( MimeUnrender ct a, BuildHeadersTo ls, BuildHeaderKeysTo ls, ReflectMethod method, cts' ~ (ct ': cts), SupportsServantReflex t m ) => HasClient t m (Verb method status cts' (Headers ls a)) tag where type Client t m (Verb method status cts' (Headers ls a)) tag = Event t tag -> m (Event t (ReqResult tag (Headers ls a))) clientWithRouteAndResultHandler Proxy _ _ req baseurl opts wrap trigs = do let method = E.decodeUtf8 $ reflectMethod (Proxy :: Proxy method) resp <- fmap runIdentity <$> performRequestsCT (Proxy :: Proxy ct) method (constDyn $ Identity req') baseurl opts trigs wrap $ toHeaders <$> resp where req' = req { respHeaders = OnlyHeaders (Set.fromList (buildHeaderKeysTo (Proxy :: Proxy ls))) } -- HEADERS Verb (No content) -- instance {-# OVERLAPPABLE #-} ( BuildHeadersTo ls, BuildHeaderKeysTo ls, ReflectMethod method, SupportsServantReflex t m ) => HasClient t m (Verb method status cts (Headers ls NoContent)) tag where type Client t m (Verb method status cts (Headers ls NoContent)) tag = Event t tag -> m (Event t (ReqResult tag (Headers ls NoContent))) clientWithRouteAndResultHandler Proxy _ _ req baseurl opts wrap trigs = do let method = E.decodeUtf8 $ reflectMethod (Proxy :: Proxy method) resp <- fmap runIdentity <$> performRequestsNoBody method (constDyn $ Identity req') baseurl opts trigs wrap $ toHeaders <$> resp where req' = req {respHeaders = OnlyHeaders (Set.fromList (buildHeaderKeysTo (Proxy :: Proxy ls))) } -- HEADER -- > newtype Referer = Referer { referrer :: Text } -- > deriving (Eq, Show, Generic, FromText, ToHttpApiData) -- > -- > -- GET /view-my-referer -- > type MyApi = "view-my-referer" :> Header "Referer" Referer :> Get '[JSON] Referer -- > -- > -- > viewReferer :: Maybe Referer -> ExceptT String IO Book -- > viewReferer = client myApi host -- > where host = BaseUrl Http "localhost" 8080 -- > -- then you can just use "viewRefer" to query that endpoint -- > -- specifying Nothing or e.g Just "http://haskell.org/" as arguments instance (KnownSymbol sym, ToHttpApiData a, HasClient t m sublayout tag, SupportsServantReflex t m) => HasClient t m (Header sym a :> sublayout) tag where type Client t m (Header sym a :> sublayout) tag = Dynamic t (Either Text a) -> Client t m sublayout tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap eVal = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t (Servant.Common.Req.addHeader hname eVal req) baseurl opts wrap where hname = T.pack $ symbolVal (Proxy :: Proxy sym) -- | Using a 'HttpVersion' combinator in your API doesn't affect the client -- functions. instance HasClient t m sublayout tag => HasClient t m (HttpVersion :> sublayout) tag where type Client t m (HttpVersion :> sublayout) tag = Client t m sublayout tag clientWithRouteAndResultHandler Proxy = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) -- | Using a 'Summary' combinator in your API doesn't affect the client -- functions. instance (HasClient t m sublayout tag, KnownSymbol sym) => HasClient t m (Summary sym :> sublayout) tag where type Client t m (Summary sym :> sublayout) tag = Client t m sublayout tag clientWithRouteAndResultHandler Proxy = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) -- | If you use a 'QueryParam' in one of your endpoints in your API, -- the corresponding querying function will automatically take -- an additional argument of the type specified by your 'QueryParam', -- 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 this value in the query string. -- -- You can control how values for your type are turned into -- text by specifying a 'ToHttpApiData' instance for your type. -- -- Example: -- -- > type MyApi = "books" :> QueryParam "author" Text :> Get '[JSON] [Book] -- > -- > myApi :: Proxy MyApi -- > myApi = Proxy -- > -- > getBooksBy :: Maybe Text -> ExceptT String IO [Book] -- > getBooksBy = client myApi host -- > where host = BaseUrl Http "localhost" 8080 -- > -- then you can just use "getBooksBy" to query that endpoint. -- > -- 'getBooksBy Nothing' for all books -- > -- 'getBooksBy (Just "Isaac Asimov")' to get all books by Isaac Asimov instance (KnownSymbol sym, ToHttpApiData a, HasClient t m sublayout tag, Reflex t) => HasClient t m (QueryParam sym a :> sublayout) tag where type Client t m (QueryParam sym a :> sublayout) tag = Dynamic t (QParam a) -> Client t m sublayout tag -- if mparam = Nothing, we don't add it to the query string clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap mparam = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t (req {qParams = paramPair : qParams req}) baseurl opts wrap where pname = symbolVal (Proxy :: Proxy sym) --p prm = QueryPartParam $ (fmap . fmap) (toQueryParam) prm --paramPair = (T.pack pname, p mparam) p prm = QueryPartParam $ fmap qParamToQueryPart prm -- (fmap . fmap) (unpack . toQueryParam) prm paramPair = (T.pack pname, p mparam) -- | If you use a 'QueryParams' in one of your endpoints in your API, -- the corresponding querying function will automatically take -- an additional argument, a list of values of the type specified -- by your 'QueryParams'. -- -- If you give an empty list, nothing will be added to the query string. -- -- Otherwise, this function will take care -- of inserting a textual representation of your values in the query string, -- under the same query string parameter name. -- -- You can control how values for your type are turned into -- text by specifying a 'ToHttpApiData' instance for your type. -- -- Example: -- -- > type MyApi = "books" :> QueryParams "authors" Text :> Get '[JSON] [Book] -- > -- > myApi :: Proxy MyApi -- > myApi = Proxy -- > -- > getBooksBy :: [Text] -> ExceptT String IO [Book] -- > getBooksBy = client myApi host -- > where host = BaseUrl Http "localhost" 8080 -- > -- then you can just use "getBooksBy" to query that endpoint. -- > -- 'getBooksBy []' for all books -- > -- 'getBooksBy ["Isaac Asimov", "Robert A. Heinlein"]' -- > -- to get all books by Asimov and Heinlein instance (KnownSymbol sym, ToHttpApiData a, HasClient t m sublayout tag, Reflex t) => HasClient t m (QueryParams sym a :> sublayout) tag where type Client t m (QueryParams sym a :> sublayout) tag = Dynamic t [a] -> Client t m sublayout tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap paramlist = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t req' baseurl opts wrap where req' = req { qParams = (T.pack pname, params') : qParams req } pname = symbolVal (Proxy :: Proxy sym) params' = QueryPartParams $ (fmap . fmap) toQueryParam paramlist -- | If you use a 'QueryFlag' in one of your endpoints in your API, -- the corresponding querying function will automatically take -- an additional 'Bool' argument. -- -- If you give 'False', nothing will be added to the query string. -- -- Otherwise, this function will insert a value-less query string -- parameter under the name associated to your 'QueryFlag'. -- -- Example: -- -- > type MyApi = "books" :> QueryFlag "published" :> Get '[JSON] [Book] -- > -- > myApi :: Proxy MyApi -- > myApi = Proxy -- > -- > getBooks :: Bool -> ExceptT String IO [Book] -- > getBooks = client myApi host -- > where host = BaseUrl Http "localhost" 8080 -- > -- then you can just use "getBooks" to query that endpoint. -- > -- 'getBooksBy False' for all books -- > -- 'getBooksBy True' to only get _already published_ books -- TODO Bring back instance (KnownSymbol sym, HasClient t m sublayout tag, Reflex t) => HasClient t m (QueryFlag sym :> sublayout) tag where type Client t m (QueryFlag sym :> sublayout) tag = Dynamic t Bool -> Client t m sublayout tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap flag = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t req' baseurl opts wrap where req' = req { qParams = thisPair : qParams req } thisPair = (T.pack pName, QueryPartFlag flag) :: (Text, QueryPart t) pName = symbolVal (Proxy :: Proxy sym) -- | Send a raw 'XhrRequest ()' directly to 'baseurl' instance SupportsServantReflex t m => HasClient t m Raw tag where type Client t m Raw tag = Dynamic t (Either Text (XhrRequest ())) -> Event t tag -> m (Event t (ReqResult tag ())) clientWithRouteAndResultHandler _ _ _ _ baseurl _ wrap xhrs triggers = do let xhrs' = liftA2 (\x path -> case x of Left e -> Left e Right jx -> Right $ jx { _xhrRequest_url = path <> _xhrRequest_url jx } ) xhrs (showBaseUrl <$> baseurl) xhrs'' = attachPromptlyDynWith (flip (,)) xhrs' triggers :: Event t (tag, Either Text (XhrRequest ())) badReq = fmapMaybe (\(t,x) -> either (Just . (t,)) (const Nothing) x) xhrs'' :: Event t (tag, Text) okReq = fmapMaybe (\(t,x) -> either (const Nothing) (Just . (t,)) x) xhrs'' :: Event t (tag, XhrRequest ()) resps <- performRequestsAsync okReq wrap $ leftmost [ uncurry RequestFailure <$> badReq , evalResponse (const $ Right ()) <$> resps ] -- | If you use a 'ReqBody' in one of your endpoints in your API, -- the corresponding querying function will automatically take -- an additional argument of the type specified by your 'ReqBody'. -- That function will take care of encoding this argument as JSON and -- of using it as the request body. -- -- All you need is for your type to have a 'ToJSON' instance. -- -- Example: -- -- > type MyApi = "books" :> ReqBody '[JSON] Book :> Post '[JSON] Book -- > -- > myApi :: Proxy MyApi -- > myApi = Proxy -- > -- > addBook :: Book -> ExceptT String IO Book -- > addBook = client myApi host -- > where host = BaseUrl Http "localhost" 8080 -- > -- then you can just use "addBook" to query that endpoint instance (MimeRender ct a, HasClient t m sublayout tag, Reflex t) => HasClient t m (ReqBody (ct ': cts) a :> sublayout) tag where type Client t m (ReqBody (ct ': cts) a :> sublayout) tag = Dynamic t (Either Text a) -> Client t m sublayout tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap body = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t req' baseurl opts wrap where req' = req { reqBody = bodyBytesCT } ctProxy = Proxy :: Proxy ct ctString = T.pack $ show $ contentType ctProxy bodyBytesCT = Just $ (fmap . fmap) (\b -> (mimeRender ctProxy b, ctString)) body -- | Make the querying function append @path@ to the request path. instance (KnownSymbol path, HasClient t m sublayout tag, Reflex t) => HasClient t m (path :> sublayout) tag where type Client t m (path :> sublayout) tag = Client t m sublayout tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap = clientWithRouteAndResultHandler (Proxy :: Proxy sublayout) q t (prependToPathParts (pure (Right $ T.pack p)) req) baseurl opts wrap where p = symbolVal (Proxy :: Proxy path) instance HasClient t m api tag => HasClient t m (Vault :> api) tag where type Client t m (Vault :> api) tag = Client t m api tag clientWithRouteAndResultHandler Proxy = clientWithRouteAndResultHandler (Proxy :: Proxy api) instance HasClient t m api tag => HasClient t m (RemoteHost :> api) tag where type Client t m (RemoteHost :> api) tag = Client t m api tag clientWithRouteAndResultHandler Proxy = clientWithRouteAndResultHandler (Proxy :: Proxy api) instance HasClient t m api tag => HasClient t m (IsSecure :> api) tag where type Client t m (IsSecure :> api) tag = Client t m api tag clientWithRouteAndResultHandler Proxy = clientWithRouteAndResultHandler (Proxy :: Proxy api) instance (HasClient t m api tag, Reflex t) => HasClient t m (BasicAuth realm usr :> api) tag where type Client t m (BasicAuth realm usr :> api) tag = Dynamic t (Maybe BasicAuthData) -> Client t m api tag clientWithRouteAndResultHandler Proxy q t req baseurl opts wrap authdata = clientWithRouteAndResultHandler (Proxy :: Proxy api) q t req' baseurl opts wrap where req' = req { authData = Just authdata } -- instance HasClient t m subapi => -- HasClient t m (WithNamedConfig name config subapi) where -- type Client t m (WithNamedConfig name config subapi) = Client t m subapi -- clientWithRoute Proxy q = clientWithRoute (Proxy :: Proxy subapi) q {- Note [Non-Empty Content Types] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Rather than have instance (..., cts' ~ (ct ': cts)) => ... cts' ... It may seem to make more sense to have: instance (...) => ... (ct ': cts) ... But this means that if another instance exists that does *not* require non-empty lists, but is otherwise more specific, no instance will be overall more specific. This in turn generally means adding yet another instance (one for empty and one for non-empty lists). -} -- SUPPORT FOR servant-auth -- -- For JavaScript clients we should be sending/storing JSON web tokens in a -- way that is inaccessible to JavaScript. -- -- For @servant-auth@ this is done with HTTP-only cookies. In a Reflex-DOM -- app this means the @servant-auth@ client should only verify that the API -- supports Cookie-based authentication but do nothing with the token -- directly. -- @HasCookieAuth auths@ is nominally a redundant constraint, but ensures -- we're not trying to rely on cookies when the API does not use them. instance (HasCookieAuth auths, HasClient t m api tag) => HasClient t m (Auth.Auth auths a :> api) tag where type Client t m (Auth.Auth auths a :> api) tag = Client t m api tag clientWithRouteAndResultHandler Proxy = clientWithRouteAndResultHandler (Proxy :: Proxy api) type family HasCookieAuth xs :: Constraint where HasCookieAuth (Auth.Cookie ': xs) = () HasCookieAuth (x ': xs) = HasCookieAuth xs HasCookieAuth '[] = CookieAuthNotEnabled class CookieAuthNotEnabled