gRPC clients
There are several options for building clients: you can choose between optics, records, and TypeApplications
. Let’s consider in detail an example client for the following service:
service Service {
rpc getPerson (PersonRequest) returns (Person);
rpc newPerson (Person) returns (PersonRequest);
rpc allPeople (google.protobuf.Empty) returns (stream Person);
}
Regardless of the approach we decide to use, we can construct a basic CLI for the client this way:
import System.Environment
main :: IO ()
main = do
let config = grpcClientConfigSimple "127.0.0.1" 8080 False
Right client <- setup config
args <- getArgs
case args of
["watch"] -> watching client
["get", idp] -> get client idp
["add", nm, ag] -> add client nm ag
_ -> putStrLn "unknown command"
Where watch
, get
and add
are the only valid 3 commands that our CLI is going to accept and call each respective service. The setup
function is responsible from initializing the
Using optics
The simplest way to call methods is to use the optics
-based API. In that case, your setup is done using initGRpc
, which receives the configuration.
main :: IO ()
main = do ...
where setup config = initGRpc config msgProtoBuf
To call a method, you use the corresponding getter (for those familiar with optics, a version of a lens which does not allow to set). This means that your code reads client ^. #method
, where client
is the value obtained previously in the call to initGRpc
.
{-# language OverloadedLabels #-}
get :: GRpcConnection QuickstartService -> String -> IO ()
get client idPerson = do
let req = record1 (read idPerson)
putStrLn $ "GET: is there some person with id: " ++ idPerson ++ "?"
res <- client ^. #getPerson $ req
putStrLn $ "GET: response was: " ++ show res
Notice the use of read
to convert the strings to the appropiate type. Be careful, though, since that function throws an exception if the string is not a proper number! In a realistic scenario you should use readMaybe
from Text.Read
and handle the appropiate cases.
Using this approach you must also use the optics-based interface to the terms. As a quick reminder: you use record
to build new values, and use value ^. #field
to access a field. The rest of the methods look as follows:
add :: GRpcConnection QuickstartService -> String -> String -> IO ()
add client nm ag = do
let p = record (Nothing, T.pack nm, read ag)
putStrLn $ "ADD: creating new person " ++ nm ++ " with age " ++ ag
res <- client ^. #newPerson $ p
putStrLn $ "ADD: was creating successful? " ++ show res
watching :: GRpcConnection QuickstartService -> IO ()
watching client = do
replies <- client ^. #allPeople
runConduit $ replies .| C.mapM_ print
Using records
This option is a bit more verbose but it’s also more explicit with the types. Furthermore, it allows us to use our Haskell data types, we are not forced to use plain terms. As discussed several times, this is important to ensure that Haskell types are not mere shadows of the schema types.
We need to define a new record type (hence the name) that declares the services our client is going to consume. The names of the fields must match the names of the methods in the service, optionally prefixed by a common string. The prefix may also be empty, which means that the names in the record are exactly those in the service definition. In this case, we are prepending call_
to each of them:
import GHC.Generics (Generic)
import Mu.GRpc.Client.Record
data Call = Call
{ call_getPerson :: MPersonRequest -> IO (GRpcReply MPerson)
, call_newPerson :: MPerson -> IO (GRpcReply MPersonRequest)
, call_allPeople :: IO (ConduitT () (GRpcReply MPerson) IO ())
} deriving Generic
Note that we had to derive Generic
. We also need to tweak our setup
function a little bit:
{-# language TypeApplications #-}
main :: IO ()
main = do ...
where setup config = buildService @Service @"call_" <$> setupGrpcClient' config
Instead of building our client directly, we need to call buildService
(and enable TypeApplications
) to create the actual gRPC client. There are two type arguments to be explicitly given: the first one is the Service
definition we want a client for, and the second one is the prefix in the record (in our case, this is call_
). In the case you want an empty prefix, you write @""
in that second position.
After that, let’s have a look at an example implementation of the three service calls. Almost as before, except that we use call_
followed by the name of the method.
get :: Call -> String -> IO ()
get client idPerson = do
let req = MPersonRequest $ read idPerson
putStrLn $ "GET: is there some person with id: " ++ idPerson ++ "?"
res <- call_getPerson client req
putStrLn $ "GET: response was: " ++ show res
add :: Call -> String -> String -> IO ()
add client nm ag = do
let p = MPerson Nothing (T.pack nm) (read ag)
putStrLn $ "ADD: creating new person " ++ nm ++ " with age " ++ ag
res <- call_newPerson client p
putStrLn $ "ADD: was creating successful? " ++ show res
watching :: Call -> IO ()
watching client = do
replies <- call_allPeople client
runConduit $ replies .| C.mapM_ print
Using TypeApplications
With TypeApplications
none of the above is needed, all you need to do is call gRpcCall
with the appropiate service name as a type-level string, and the rest just magically works! ✨
If you are not familiar with TypeApplications
, you can check this, that and this.
import Mu.GRpc.Client.TyApps
main = do ...
where setup config = setupGrpcClient' config
get :: GrpcClient -> String -> IO ()
get client idPerson = do
let req = MPersonRequest $ read idPerson
putStrLn $ "GET: is there some person with id: " ++ idPerson ++ "?"
response :: GRpcReply MPerson
<- gRpcCall @Service @"getPerson" client req
putStrLn $ "GET: response was: " ++ show response
Notice that the type signatures of our functions needed to change to receive the GrpcClient
as an argument, instead of our custom record type.
add :: GrpcClient -> String -> String -> IO ()
add client nm ag = do
let p = MPerson Nothing (T.pack nm) (read ag)
putStrLn $ "ADD: creating new person " ++ nm ++ " with age " ++ ag
response :: GRpcReply MPersonRequest
<- gRpcCall @Service @"newPerson" client p
putStrLn $ "ADD: was creating successful? " ++ show response
We are being a bit more explicit with the types here (for example, response :: GRpcReply MPersonRequest
) to help a bit the show
function because GHC is not able to infer the type on its own.
watching :: GrpcClient -> IO ()
watching client = do
replies <- gRpcCall @Service @"allPeople" client
runConduit $ replies .| C.mapM_ (print :: GRpcReply MPerson -> IO ())
Here though, while mapping print
to the Conduit
, we needed to add a type annotation because the type was ambiguous… I think it’s a small price to pay in exchange for the terseness. 🤑
To see a working example you can check all the code at the example with persistent.