-- | Make calls to an API defined using "Servant.API" from your "React.Flux" stores. -- __You must call 'initAjax' from your main function for this to work.__ -- For now, this module only works with JSON. -- -- The general pattern I currently use is to create a store action to trigger the request -- and another store action to process the response. Only the store action to trigger -- the request is exported. This does lead to a bit of tedious boilerplate since you need -- actions for each request, so I am still searching for a better API that perhaps takes -- advantage of the servant API type-level computation to reduce the boilerplate. For now, -- until I figure out a better way, this direct approach does work. If you have any ideas -- for a good API, please open an issue on bitbucket (even if you don't have full code)! -- -- As an example, say that the API consists of two methods: -- -- @ -- type GetUser = "user" :> Capture "user_id" UserId :> Get '[JSON] User -- type SetUser = "user" :> Capture "user_id" UserId :> ReqBody '[JSON] User :> Post '[JSON] () -- type MyAPI = GetUser :\<|\> SetUser -- @ -- -- I would create a store as follows: -- -- @ -- data RequestStatus = NoPendingRequest | PendingRequest | PreviousRequestHadError String -- -- data UserStore = UserStore -- { users :: Map.HashMap UserId User -- , reqStatus :: RequestStatus -- } -- -- data UserStoreAction = RequestUser UserId -- | RequestUserResponse UserId (Either (Int,String) User) -- | UpdateUser UserId User -- | UpdateUserResponse UserId (Either (Int, String) ()) -- deriving (Show, Generic, NFData) -- -- cfg :: ApiRequestConfig MyAPI -- cfg = ApiRequestConfig "https://www.example.com" NoTimeout -- -- instance StoreData UserStore where -- type StoreAction UserStore = UserStoreAction -- -- transform (RequestUser uid) us = do -- request cfg (Proxy :: Proxy GetUser) uid $ -- \\r -> return [SomeStoreAction userStore $ RequestUserResponse uid r] -- return $ us {reqStatus = PendingRequest} -- -- transform (RequestUserResponse _ (Left (_errCode, err))) us = -- return $ us {reqStatus = PreviousRequestHadError err} -- -- transform (RequestUserResponse uid (Right u)) us = -- return $ us { reqStatus = NoPendingRequest -- , users = Map.insert uid u (users us) -- } -- -- transform (UpdateUser uid u) us = do -- request cfg (Proxy :: Proxy SetUser) uid u $ -- \\r -> return [SomeStoreAction userStore $ UpdateUserResponse uid r] -- return $ us { reqStatus = PendingRequest -- , users = Map.insert uid u (users us) -- } -- -- transform (UpdateUserResponse uid (Left (_errCode, err))) us = -- return $ us { reqStatus = PreviousRequestHadError err -- , users = Map.delete uid (users us) -- } -- -- transform (UpdateUserResponse _ (Right ())) us = -- return $ us { reqStatus = NoPendingRequest} -- -- userStore :: ReactStore UserStore -- userStore = mkStore $ UserStore Map.empty NoPendingRequest -- @ module React.Flux.Addons.Servant( HandleResponse , RequestTimeout(..) , ApiRequestConfig(..) , request , HasAjaxRequest(..) ) where import React.Flux import React.Flux.Ajax import Servant.Utils.Links import Servant.API import GHC.TypeLits import Data.Typeable (Proxy(..)) import Data.Aeson import Data.Monoid ((<>)) import qualified Data.Text as T import qualified Data.Text.Encoding as T #ifdef __GHCJS__ import Data.JSString (JSString) import GHCJS.Types (JSVal, nullRef) import GHCJS.Marshal (toJSVal_aeson, FromJSVal(..)) import Data.JSString.Text (textToJSString) import qualified Data.JSString as JSS jsPack :: String -> JSString jsPack = JSS.pack jsUnpack :: JSString -> String jsUnpack = JSS.unpack jsIntercalate :: JSString -> [JSString] -> JSString jsIntercalate = JSS.intercalate #else import Data.List (intercalate) type JSVal = () type JSString = String nullRef :: JSVal nullRef = () jsPack :: String -> JSString jsPack = id jsUnpack :: JSString -> String jsUnpack = id jsIntercalate :: JSString -> [JSString] -> JSString jsIntercalate = intercalate textToJSString :: T.Text -> JSString textToJSString = T.unpack fromJSVal :: JSVal -> IO a fromJSVal = undefined toJSVal_aeson :: a -> IO JSString toJSVal_aeson = undefined #endif -- | Internal state used when building the request. data Request = Request { segments :: [JSString] , rHeaders :: [(JSString, JSString)] , rQuery :: [(JSString, JSString)] , rBody :: IO JSVal , rTimeout :: RequestTimeout } -- | When a response from the server arrives, it is either an error or a valid response. An error -- is turned into a 'Left' value consisting of the HTTP response code and the response body. If -- a 200 HTTP response is received, it is parsed to a value according to the servant API definition -- and passed as a 'Right' value. You must then turn the response into a store action. I suggest -- that you just pass the value along directly to a store action without any computation; the computation -- can happen inside the store. type HandleResponse a = Either (Int,String) a -> IO [SomeStoreAction] -- | Settings for requests built using this module. data ApiRequestConfig api = ApiRequestConfig { urlPrefix :: JSString -- ^ A prefix for all requests; it should include the domain and any prefix path that is required. -- To this prefix a forward slash is appended and then the path built using the Servant API definition. , timeout :: RequestTimeout -- ^ The timeout to use for requests. If a timeout occurs, a 'Left' value with code 504 is passed to -- 'HandleResponse'. } -- | Make a request to a servant endpoint. __You must call 'initAjax' from your main function for this to work.__ -- -- 'request' takes the 'ApiRequestConfig', a proxy for the endpoint, -- parameters for the request (request body, query params, path captures, etc.), and value of type 'HandleResponse'. -- The result of 'request' is then a value of type @IO ()@. In order to type-check that the proper values for -- the request body, path captures, etc. are passed, the 'MkRequest' associated type is used. 'MkRequest' expands -- to a function with one argument for each path piece and an argument for the 'HandleResponse'. For example, -- -- @ -- type GetUser = "user" :> Capture "user_id" UserId :> Get '[JSON] User -- type SetUser = "user" :> Capture "user_id" UserId :> ReqBody '[JSON] User :> Post '[JSON] () -- type MyAPI = GetUser :\<|\> SetUser -- @ -- -- Then -- -- @ -- MkRequest GetUser ~ UserId -> HandleResponse User -> IO () -- MkRequest SetUser ~ UserId -> User -> HandleResponse () -> IO () -- @ -- -- so that -- -- @ -- request cfg (Proxy :: Proxy GetUser) :: UserId -> HandleResponse User -> IO () -- request cfg (Proxy :: Proxy SetUser) :: UserId -> User -> HandleResponse () -> IO () -- @ request :: (IsElem endpoint api, HasAjaxRequest endpoint) => ApiRequestConfig api -> Proxy endpoint -> MkRequest endpoint request (ApiRequestConfig p t) endpoint = toRequest endpoint (Request [p] [] [] (pure nullRef) t) -- | A class very similar to "Servant.Utils.Links". You shouldn't need to use this class directly: instead -- use 'request'. Having said that, the 'MkRequest' type defined in this typeclass is important as it determines -- what values you must pass to 'request' to obtain a proper request. class HasAjaxRequest endpoint where type MkRequest endpoint toRequest :: Proxy endpoint -> Request -> MkRequest endpoint instance (ToJSON a, HasAjaxRequest sub) => HasAjaxRequest (ReqBody '[JSON] a :> sub) where type MkRequest (ReqBody '[JSON] a :> sub) = a -> MkRequest sub toRequest _ r body = toRequest (Proxy :: Proxy sub) (r { rBody = toJSVal_aeson body >>= js_JSONstringify , rHeaders = rHeaders r ++ [("Content-Type", "application/json")] }) instance (KnownSymbol sym, HasAjaxRequest sub) => HasAjaxRequest (sym :> sub) where type MkRequest (sym :> sub) = MkRequest sub toRequest _ r = toRequest (Proxy :: Proxy sub) (r { segments = segments r ++ [seg]}) where seg = jsPack $ symbolVal (Proxy :: Proxy sym) instance (ToHttpApiData v, HasAjaxRequest sub) => HasAjaxRequest (Capture sym v :> sub) where type MkRequest (Capture sym v :> sub) = v -> MkRequest sub toRequest _ r v = toRequest (Proxy :: Proxy sub) (r { segments = segments r ++ [v'] }) where v' = jsPack $ T.unpack $ toUrlPiece v instance (KnownSymbol sym, ToHttpApiData a, HasAjaxRequest sub) => HasAjaxRequest (Header sym a :> sub) where type MkRequest (Header sym a :> sub) = Maybe a -> MkRequest sub toRequest _ r Nothing = toRequest (Proxy :: Proxy sub) r toRequest _ r (Just a) = toRequest (Proxy :: Proxy sub) (r { rHeaders = rHeaders r ++ [(sym',a')]}) where sym' = jsPack $ symbolVal (Proxy :: Proxy sym) a' = jsPack $ T.unpack $ toUrlPiece a instance (KnownSymbol sym, ToHttpApiData a, HasAjaxRequest sub) => HasAjaxRequest (QueryParam sym a :> sub) where type MkRequest (QueryParam sym a :> sub) = Maybe a -> MkRequest sub toRequest _ r Nothing = toRequest (Proxy :: Proxy sub) r toRequest _ r (Just a) = toRequest (Proxy :: Proxy sub) r { rQuery = rQuery r ++ [(sym', a')]} where sym' = jsPack $ symbolVal (Proxy :: Proxy sym) a' = jsPack $ T.unpack $ toUrlPiece a instance (ReflectMethod m, FromJSON a) => HasAjaxRequest (Verb m s '[JSON] a) where type MkRequest (Verb m s '[JSON] a) = HandleResponse a -> IO () toRequest _ r handler = do body <- rBody r let query :: JSString = case rQuery r of [] -> "" qs -> "?" <> jsIntercalate "&" (map (\(x,y) -> x <> "=" <> y) qs) let req = AjaxRequest { reqMethod = textToJSString $ T.decodeUtf8 $ reflectMethod (Proxy :: Proxy m) , reqURI = jsIntercalate "/" (segments r) <> query , reqHeaders = rHeaders r ++ [("Accept", "application/json")] , reqBody = body , reqTimeout = rTimeout r } ajax req $ \resp -> if respStatus resp == 200 then do j <- js_JSONParse $ respResponseText resp mv <- fromJSVal j case mv of Nothing -> handler $ Left (500, "Unable to convert response body") Just v -> case fromJSON v of Success v' -> handler $ Right v' Error e -> handler $ Left (500, e) else handler $ Left (respStatus resp, jsUnpack $ respResponseText resp) #ifdef __GHCJS__ foreign import javascript unsafe "JSON['parse']($1)" js_JSONParse :: JSString -> IO JSVal foreign import javascript unsafe "JSON['stringify']($1)" js_JSONstringify :: JSVal -> IO JSVal #else js_JSONParse :: JSString -> IO JSVal js_JSONParse _ = return () js_JSONstringify :: JSString -> IO JSVal js_JSONstringify _ = return () #endif