packages feed

mig-client (empty) → 0.1.0.0

raw patch · 4 files changed

+540/−0 lines, 4 filesdep +basedep +bytestringdep +containers

Dependencies added: base, bytestring, containers, http-api-data, http-client, http-media, http-types, mig, mtl, text

Files

+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Anton Kholomiov (c) 2023++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Author name here nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,4 @@+# mig-client++Library to build HTTP-clients.+See tutorial on how to build clients: [Client article](https://anton-k.github.io/mig/08-client.html).
+ mig-client.cabal view
@@ -0,0 +1,56 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.35.2.+--+-- see: https://github.com/sol/hpack++name:               mig-client+version:            0.1.0.0+synopsis:           Build http-clients from API definition for mig servers+description:        With this library we can build client functions for HTTP-applications+                    from the same code as server definition.+category:           Web+homepage:           https://github.com/anton-k/mig#readme+bug-reports:        https://github.com/anton-k/mig/issues+author:             Anton Kholomiov+maintainer:         anton.kholomiov@gmail.com+copyright:          2023 Anton Kholomiov+license:            BSD3+license-file:       LICENSE+build-type:         Simple+extra-source-files:+    README.md++source-repository head+  type: git+  location: https://github.com/anton-k/mig++library+  exposed-modules:+      Mig.Client+  other-modules:+      Paths_mig_client+  hs-source-dirs:+      src+  default-extensions:+      OverloadedStrings+      TypeFamilies+      OverloadedRecordDot+      DuplicateRecordFields+      LambdaCase+      DerivingStrategies+      StrictData+      AllowAmbiguousTypes+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -Wunused-packages+  build-depends:+      base >=4.7 && <5+    , bytestring+    , containers+    , http-api-data+    , http-client+    , http-media+    , http-types+    , mig >=0.2+    , mtl+    , text+  default-language: GHC2021
+ src/Mig/Client.hs view
@@ -0,0 +1,450 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE UndecidableInstances #-}++-- | Functions to create http-clients from the same code as server or API schema+module Mig.Client (+  ToClient (..),+  Client (..),+  ClientConfig (..),+  runClient,+  (:|) (..),+  FromClient (..),+  getRespOrValue,+  Client' (..),+  runClient',+  MonadIO (..),+  ClientOr,+) where++import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Reader+import Data.Bifunctor+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.ByteString.Lazy qualified as BL+import Data.Kind+import Data.Map.Strict qualified as Map+import Data.Proxy+import Data.String+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import GHC.TypeLits+import Network.HTTP.Client qualified as Http+import Network.HTTP.Media.RenderHeader (RenderHeader (..))+import Network.HTTP.Types.Header (HeaderName)+import Network.HTTP.Types.Method+import Network.HTTP.Types.Status+import Web.HttpApiData++import Mig.Core++{-| Infox synonym for pair. It can be useful to stack together+many client functions in the output of @toClient@ function.+-}+data (:|) a b = a :| b++instance (ToClient a, ToClient b) => ToClient (a :| b) where+  toClient api = a :| b+    where+      (a, b) = toClient api++  clientArity = clientArity @(a, b)++class MapRequest a where+  mapRequest :: (Http.Request -> Http.Request) -> (a -> a)+  mapCapture :: (CaptureMap -> CaptureMap) -> (a -> a)++instance MapRequest (Client a) where+  mapRequest f (Client a) = Client (\conf capt -> a conf capt . f)+  mapCapture f (Client a) = Client (\conf capt req -> a conf (f capt) req)++instance MapRequest (Send method Client a) where+  mapRequest f (Send client) = Send (mapRequest f client)+  mapCapture f (Send client) = Send (mapCapture f client)++instance (MapRequest b) => MapRequest (a -> b) where+  mapRequest f a = mapRequest f . a+  mapCapture f a = mapCapture f . a++instance (MapRequest a, MapRequest b) => MapRequest (a, b) where+  mapRequest f (a, b) = (mapRequest f a, mapRequest f b)+  mapCapture f (a, b) = (mapCapture f a, mapCapture f b)++instance (MapRequest a, MapRequest b) => MapRequest (a :| b) where+  mapRequest f (a :| b) = (mapRequest f a :| mapRequest f b)+  mapCapture f (a :| b) = (mapCapture f a :| mapCapture f b)++instance (MapRequest a, MapRequest b, MapRequest c) => MapRequest (a, b, c) where+  mapRequest f (a, b, c) = (mapRequest f a, mapRequest f b, mapRequest f c)+  mapCapture f (a, b, c) = (mapCapture f a, mapCapture f b, mapCapture f c)++instance (MapRequest a, MapRequest b, MapRequest c, MapRequest d) => MapRequest (a, b, c, d) where+  mapRequest f (a, b, c, d) = (mapRequest f a, mapRequest f b, mapRequest f c, mapRequest f d)+  mapCapture f (a, b, c, d) = (mapCapture f a, mapCapture f b, mapCapture f c, mapCapture f d)++{-| Creates http-client from server definition.++The result adapts to decalred types. It creates so many client functions as+the arity of the tuple in the result. The server can have more handlers than in the result+Routes from result definition and server definition are matched in the same order as they are declared.++The type of the client is derived from the type signature of the result. The information+on paths for handlers is derived from server definition.++To use the same code for both client and server it is convenient to declare+signatures for handlers as type synonyms parameterized by server monad.+And in the server implementation monad is going to be something IO-based but+in client handlers it will be `Client`-monad.++For example for a server:++> type Hello m = Capture "name" Text -> Get m (Resp Text)+> type Add m = Query "a" Int -> Query "b" Int -> Get m (Resp Int)+>+> server :: Server IO+> server = "api" /.+>   mconcat+>    [ "hello" /. helloHandler+>    , "add" /. addHandler+>    ]+>+> helloHandler :: Hello IO+> helloHandler (Capture name) = Send $ pure $ ok $ "Hello " <> name+>+> addHandler :: Add IO+> addHandler (Query a) (Query b) = Send $ pure $ ok (a + b)++We can define the client and reuse type signatures that we have defined in the server code:++> helloClient :: Hello Client+> addClient :: Add Client+>+> helloClient :| addClient = toClient server++If there is no definition for server. For example if we write implementation for+some external server or API provided by third party we can use recursive definition in the server.+For example if there is no haskell implementation for the server in the previous example. But we+know the API of the application we can define client with recursive definition:++> type Hello m = Capture "name" Text -> Get m (Resp Text)+> type Add m = Query "a" Int -> Query "b" Int -> Get m (Resp Int)+>+> helloClient :: Hello Client+> addClient :: Add Client+>+> helloClient :| addClient = toClient server+>+> server :: Server Client+> server = "api" /.+>   mconcat+>    [ "hello" /. helloClient+>    , "add" /. addClient+>    ]++The code does not get stuck into recursion loop because implementation of the route handlers+is not needed to create client functions. The function @toClient@ takes into account+only type-signatures of the handlers and paths.+-}+class (MapRequest a) => ToClient a where+  -- | converts to client function+  toClient :: Server m -> a++  -- | how many routes client has+  clientArity :: Int++-- | Config to run the clients+data ClientConfig = ClientConfig+  { port :: Int+  -- ^ port to connect to+  , manager :: Http.Manager+  -- ^ HTTP-manager+  }++-- | The client monad. All errors are unified to Lazy.ByteString.+newtype Client a = Client (ClientConfig -> CaptureMap -> Http.Request -> IO (RespOr AnyMedia BL.ByteString a))+  deriving (Functor)++instance Applicative Client where+  pure a = Client $ const $ const $ const $ pure $ pureResp a+  (<*>) = ap++pureResp :: a -> RespOr AnyMedia BL.ByteString a+pureResp a = RespOr $ Right $ Resp ok200 [] (Just a)++instance Monad Client where+  (Client ma) >>= mf = Client $ \config captureValues req -> do+    RespOr eResp <- ma config captureValues req+    case eResp of+      Right resp -> case resp.body of+        Just body -> case mf body of+          Client run -> run config captureValues req+        Nothing -> pure (RespOr $ Right $ Resp resp.status resp.headers Nothing)+      Left err -> pure (RespOr $ Left err)++instance MonadIO Client where+  liftIO act = Client $ \_ _ _ -> pureResp <$> act++-- | Runs client. It calls client handler and fetches the result.+runClient :: ClientConfig -> Client a -> IO (RespOr AnyMedia BL.ByteString a)+runClient config (Client act) = act config mempty Http.defaultRequest++instance (IsMethod method, FromReqBody (RespMedia a) (RespBody a), IsResp a) => ToClient (Send method Client a) where+  toClient api =+    mapRequest (setRequestMethod (toMethod @method)) $+      Send $+        fmap ok $+          httpSend @(RespMedia a) @(RespBody a) (getHeadPath $ Server $ fillCaptures $ unServer api)++  clientArity = 1++instance (ToClient a, ToClient b) => ToClient (a, b) where+  toClient (Server api) = (toClient (Server apiA), toClient (Server apiB))+    where+      (apiA, apiB) = bimap fromFlatApi fromFlatApi $ Prelude.splitAt (clientArity @a) (flatApi api)++  clientArity = clientArity @a + clientArity @b++instance (ToClient a, ToClient b, ToClient c) => ToClient (a, b, c) where+  toClient api = (a, b, c)+    where+      (a, (b, c)) = toClient api++  clientArity = clientArity @(a, (b, c))++instance (ToClient a, ToClient b, ToClient c, ToClient d) => ToClient (a, b, c, d) where+  toClient api = (a, b, c, d)+    where+      (a, (b, c, d)) = toClient api++  clientArity = clientArity @(a, (b, c, d))++getHeadPath :: Server m -> Path+getHeadPath (Server api) = case flatApi api of+  (pathHead, _) : _ -> pathHead+  _ -> error "Not enought methods. API is empty"++setRequestMethod :: Method -> Http.Request -> Http.Request+setRequestMethod m req = req{Http.method = m}++instance (KnownSymbol sym, ToHttpApiData a, ToClient b) => ToClient (Header sym a -> b) where+  toClient api = \header -> mapRequest (addHeader header) $ toClient @b api+  clientArity = clientArity @b++instance (KnownSymbol sym, ToHttpApiData a, ToClient b) => ToClient (OptionalHeader sym a -> b) where+  toClient api = \header -> mapRequest (addOptionalHeader header) $ toClient @b api+  clientArity = clientArity @b++instance (KnownSymbol sym, ToHttpApiData a, ToClient b) => ToClient (Query sym a -> b) where+  toClient api = \query -> mapRequest (addQuery query) $ toClient @b api+  clientArity = clientArity @b++instance (KnownSymbol sym, ToClient b) => ToClient (QueryFlag sym -> b) where+  toClient api = \(QueryFlag flag) -> mapRequest (addQuery @sym @Bool (Query flag)) $ toClient @b api+  clientArity = clientArity @b++instance (KnownSymbol sym, ToHttpApiData a, ToClient b) => ToClient (Optional sym a -> b) where+  toClient api = \query -> mapRequest (addOptional query) $ toClient @b api+  clientArity = clientArity @b++instance (KnownSymbol sym, ToHttpApiData a, ToClient b) => ToClient (Capture sym a -> b) where+  toClient api = \capture -> mapCapture (addCapture capture) $ toClient @b api+  clientArity = clientArity @b++instance (ToRespBody media a, ToClient b) => ToClient (Body media a -> b) where+  toClient api = \(Body body) -> mapRequest (addBody @media body) $ toClient @b api+  clientArity = clientArity @b++instance (ToClient b) => ToClient (PathInfo -> b) where+  toClient api = \(PathInfo _path) -> toClient @b api+  clientArity = clientArity @b++instance (ToClient b) => ToClient (IsSecure -> b) where+  toClient api = \(IsSecure _val) -> toClient @b api+  clientArity = clientArity @b++instance (ToClient b) => ToClient (RawRequest -> b) where+  toClient api = \(RawRequest _val) -> toClient @b api+  clientArity = clientArity @b++instance (ToClient b) => ToClient (RawResponse -> b) where+  toClient api = \(RawResponse _val) -> toClient @b api+  clientArity = clientArity @b++addQuery :: forall sym a. (KnownSymbol sym, ToHttpApiData a) => Query sym a -> Http.Request -> Http.Request+addQuery (Query a) req = req{Http.queryString = str}+  where+    str =+      if B.null (Http.queryString req)+        then param+        else Http.queryString req <> "&" <> param++    param = fromString (symbolVal (Proxy @sym)) <> "=" <> Text.encodeUtf8 (toUrlPiece a)++addOptional :: forall sym a. (KnownSymbol sym, ToHttpApiData a) => Optional sym a -> Http.Request -> Http.Request+addOptional (Optional mVal) = case mVal of+  Nothing -> id+  Just val -> addQuery @sym (Query val)++addHeader :: forall sym a. (KnownSymbol sym, ToHttpApiData a) => Header sym a -> Http.Request -> Http.Request+addHeader (Header a) = addRequestHeader (fromString $ symbolVal (Proxy @sym), toHeader a)++addOptionalHeader :: forall sym a. (KnownSymbol sym, ToHttpApiData a) => OptionalHeader sym a -> Http.Request -> Http.Request+addOptionalHeader (OptionalHeader ma) = case ma of+  Nothing -> id+  Just a -> addHeader @sym (Header a)++addCapture :: forall sym a. (KnownSymbol sym, ToHttpApiData a) => Capture sym a -> CaptureMap -> CaptureMap+addCapture (Capture a) =+  Map.insert (fromString (symbolVal (Proxy @sym))) (toUrlPiece a)++addBody :: forall media a. (ToRespBody media a) => a -> Http.Request -> Http.Request+addBody body req =+  addRequestHeader ("Content-Type", renderHeader (toMediaType @media)) $+    req{Http.requestBody = Http.RequestBodyLBS (toRespBody @media body)}++addRequestHeader :: (HeaderName, ByteString) -> Http.Request -> Http.Request+addRequestHeader header req = req{Http.requestHeaders = header : Http.requestHeaders req}++pathToString :: CaptureMap -> Path -> ByteString+pathToString captureValues (Path path) = case path of+  [] -> mempty+  item : rest -> flip rec (Path rest) $ case item of+    StaticPath p -> p+    CapturePath p ->+      case Map.lookup p captureValues of+        Just val -> val+        Nothing -> error $ "No value for capture: " <> Text.unpack p+  where+    rec a rest = Text.encodeUtf8 a <> "/" <> pathToString captureValues rest++httpSend :: forall media a. (FromReqBody media a) => Path -> Client a+httpSend path =+  mapRequest (addRequestHeader ("Accept", renderHeader $ toMediaType @media)) $+    Client $ \config captureValues req ->+      toSend <$> Http.httpLbs (setRoute captureValues path $ setPort config.port req) config.manager+  where+    toSend :: Http.Response BL.ByteString -> RespOr AnyMedia BL.ByteString a+    toSend resp =+      RespOr $ case fromReqBody @media $ Http.responseBody resp of+        Right body -> Right $ toResp body+        Left _ -> Left $ toResp $ Http.responseBody resp+      where+        toResp :: val -> Resp AnyMedia val+        toResp = Resp (Http.responseStatus resp) (Http.responseHeaders resp) . Just++setPort :: Int -> Http.Request -> Http.Request+setPort port req = req{Http.port = port}++setRoute :: CaptureMap -> Path -> Http.Request -> Http.Request+setRoute captureValues path req = req{Http.path = pathToString captureValues path}++----------------------------------------------------------+-- from response++-- | Helper type-synonym for convenience+type ClientOr a = Client' (Either BL.ByteString a)++{-| ClientConfig in @ReaderT IO@ monad. It encapsulates typical execution+of client functions+-}+newtype Client' a = Client' (ReaderT ClientConfig IO a)+  deriving (Functor, Applicative, Monad, MonadReader ClientConfig, MonadIO)++-- | Runs the client call+runClient' :: ClientConfig -> Client' a -> IO a+runClient' config (Client' act) = runReaderT act config++{-| Class to strip away all newtype wrappers that serve for API-definition.+For example it converts the types signature for client function:++> Capture "foo" Text -> Header "bar" Int -> Get Client (Resp a)++to the version without HTTP-newtype wrappers:++> Text -> Int -> Client' (Resp a)++The instances are defined for all HTTP-newtype wrappers.+Also we can use function @getRespOrValue@ if we do not need+the http information of response.+-}+class FromClient a where+  type ClientResult a :: Type+  fromClient :: a -> ClientResult a++instance (ToRespBody (RespMedia a) (RespError a), IsResp a) => FromClient (Send method Client a) where+  type ClientResult (Send method Client a) = Client' (RespOr (RespMedia a) BL.ByteString (RespBody a))+  fromClient f = Client' $ ReaderT $ flip fromSendClient f++instance (FromClient b) => FromClient (Body media a -> b) where+  type ClientResult (Body media a -> b) = a -> ClientResult b+  fromClient f arg = fromClient (f (Body arg))++instance (FromClient b) => FromClient (Capture sym a -> b) where+  type ClientResult (Capture sym a -> b) = a -> ClientResult b+  fromClient f arg = fromClient (f (Capture arg))++instance (FromClient b) => FromClient (Query sym a -> b) where+  type ClientResult (Query sym a -> b) = a -> ClientResult b+  fromClient f arg = fromClient (f (Query arg))++instance (FromClient b) => FromClient (QueryFlag a -> b) where+  type ClientResult (QueryFlag a -> b) = Bool -> ClientResult b+  fromClient f arg = fromClient (f (QueryFlag arg))++instance (FromClient b) => FromClient (Optional sym a -> b) where+  type ClientResult (Optional sym a -> b) = Maybe a -> ClientResult b+  fromClient f arg = fromClient (f (Optional arg))++instance (FromClient b) => FromClient (Header sym a -> b) where+  type ClientResult (Header sym a -> b) = a -> ClientResult b+  fromClient f arg = fromClient (f (Header arg))++instance (FromClient b) => FromClient (OptionalHeader sym a -> b) where+  type ClientResult (OptionalHeader sym a -> b) = Maybe a -> ClientResult b+  fromClient f arg = fromClient (f (OptionalHeader arg))++instance (FromClient b) => FromClient (PathInfo -> b) where+  type ClientResult (PathInfo -> b) = ClientResult b+  fromClient f = fromClient @b (f $ PathInfo [])++instance (FromClient b) => FromClient (IsSecure -> b) where+  type ClientResult (IsSecure -> b) = ClientResult b+  fromClient f = fromClient @b (f (IsSecure True))++instance (FromClient b) => FromClient (RawRequest -> b) where+  type ClientResult (RawRequest -> b) = ClientResult b+  fromClient f = fromClient @b (f $ error "no request")++instance (FromClient b) => FromClient (RawResponse -> b) where+  type ClientResult (RawResponse -> b) = ClientResult b+  fromClient f = fromClient @b (f $ error "no response")++fromSendClient ::+  forall a method.+  (ToRespBody (RespMedia a) (RespError a), IsResp a) =>+  ClientConfig ->+  Send method Client a ->+  IO (RespOr (RespMedia a) BL.ByteString (RespBody a))+fromSendClient config (Send client) =+  joinRespOr @a <$> runClient config client++joinRespOr :: forall a. (ToRespBody (RespMedia a) (RespError a), IsResp a) => RespOr AnyMedia BL.ByteString a -> RespOr (RespMedia a) BL.ByteString (RespBody a)+joinRespOr (RespOr eResp) = RespOr $ case eResp of+  Right resp -> case resp.body of+    Just result ->+      if getStatus result == ok200+        then Right $ Resp (getStatus result) (getHeaders result) (getRespBody result)+        else Left $ Resp (getStatus result) (getHeaders result) (toRespBody @(RespMedia a) <$> getRespError result)+    Nothing -> Right $ Resp resp.status resp.headers Nothing+  Left resp -> Left $ Resp resp.status resp.headers resp.body++{-| If we need only value from the server and not HTTP-info (status, or headers)+we can omit that data with this function+-}+getRespOrValue :: RespOr media BL.ByteString a -> Either BL.ByteString a+getRespOrValue (RespOr eResp) = case eResp of+  Right resp -> maybe noContentValue Right resp.body+  Left resp -> maybe noContentValue Left resp.body+  where+    noContentValue = Left "No content in the response"