wireform-proto-0.2.0.0: src/Proto/CodeGen/Service.hs
{- | Code generation for gRPC service definitions.
Generates Haskell service interfaces from proto service definitions:
* A record type for the service with one field per RPC method
* A client record with functions for calling each method
* Type aliases for request/response types
* Method metadata (method names, streaming modes)
The generated code is transport-agnostic: it defines the interface
but does not depend on any specific HTTP/2 or gRPC library.
Users wire up the transport layer by providing implementations.
-}
module Proto.CodeGen.Service (
genServiceDecls,
genServiceDeclsQualified,
genServiceModule,
) where
import Data.Char (toLower, toUpper)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Prettyprinter
import Proto.IDL.AST
import Proto.Internal.CodeGen.Combinators (txt)
-- | Generate service declarations with a type qualifier function.
genServiceDeclsQualified :: Maybe Text -> [Text] -> (Text -> Text) -> ServiceDef -> [Doc ann]
genServiceDeclsQualified pkg scope qualify svc =
[ mempty
, genServiceDoc pkg svc
, genServerTypeQ scope qualify svc
, mempty
, genClientTypeQ scope qualify svc
, mempty
, genMethodInfos scope svc
, mempty
, genServiceMeta scope svc
]
-- | Generate all declarations for a service definition.
genServiceDecls :: Maybe Text -> [Text] -> ServiceDef -> [Doc ann]
genServiceDecls pkg scope svc =
[ mempty
, genServiceDoc pkg svc
, genServerType scope svc
, mempty
, genClientType scope svc
, mempty
, genMethodInfos scope svc
, mempty
, genServiceMeta scope svc
]
-- | Generate a complete module for a service.
genServiceModule :: Maybe Text -> Text -> ServiceDef -> Doc ann
genServiceModule pkg prefix svc =
let modName = prefix <> "." <> hsModuleName' (fromMaybe "" pkg) <> "." <> hsTypeName' (svcName svc)
in vsep
[ txt "{-# LANGUAGE OverloadedStrings #-}"
, txt "-- | Generated gRPC service interface for " <> pretty (svcName svc)
, txt "module " <> pretty modName <> txt " where"
, mempty
, txt "import Data.ByteString (ByteString)"
, txt "import Data.Text (Text)"
, txt "import Proto.Encode (MessageEncode, encodeMessage)"
, txt "import Proto.Decode (MessageDecode, decodeMessage)"
, mempty
, vsep (genServiceDecls pkg [] svc)
]
genServiceDoc :: Maybe Text -> ServiceDef -> Doc ann
genServiceDoc pkg svc =
let qualified = maybe (svcName svc) (\p -> p <> "." <> svcName svc) pkg
protoDoc = case svcDoc svc of
Nothing -> []
Just doc ->
let ls = T.lines doc
in case ls of
[] -> []
(first_ : rest) ->
[txt "-- | " <> pretty first_]
<> fmap (\l -> txt "-- " <> pretty l) rest
<> [txt "--"]
header = case protoDoc of
[] -> [txt "-- | gRPC service @" <> pretty qualified <> txt "@."]
_ -> protoDoc <> [txt "-- gRPC service @" <> pretty qualified <> txt "@."]
in vsep $
header
<> [ txt "--"
, txt "-- Methods:"
, vsep
( fmap
( \r ->
txt "-- * "
<> pretty (rpcName r)
<> txt " ("
<> streamLabel (rpcInputStr r)
<> pretty (rpcInput r)
<> txt " -> "
<> streamLabel (rpcOutputStr r)
<> pretty (rpcOutput r)
<> txt ")"
)
(svcRpcs svc)
)
]
streamLabel :: StreamQualifier -> Doc ann
streamLabel NoStream = mempty
streamLabel Streaming = txt "stream "
-- | Generate the server handler record type.
genServerType :: [Text] -> ServiceDef -> Doc ann
genServerType scope svc =
let tyN = svcTypeName scope svc <> "Server"
in vsep
[ txt "-- | Server handler record for @" <> pretty (svcName svc) <> txt "@."
, txt "-- Each field is a handler function for one RPC method."
, txt "-- Implement all fields to create a server."
, txt "data " <> pretty tyN <> txt " m = " <> pretty tyN
, indent 2 (braceFields (fmap (genServerField scope) (svcRpcs svc)))
]
genServerField :: [Text] -> RpcDef -> Doc ann
genServerField scope rpc =
let fname = lowerFirst' (snakeToCamel' (rpcName rpc)) <> "Handler"
fieldLine = pretty (escapeReserved' fname) <+> txt "::" <+> genRpcServerType scope rpc
in case rpcDoc rpc of
Nothing -> fieldLine
Just doc ->
let ls = T.lines doc
in case ls of
[] -> fieldLine
(first_ : rest) ->
vsep $
[fieldLine]
<> [indent 2 (txt "-- ^ " <> pretty first_)]
<> fmap (\l -> indent 2 (txt "-- " <> pretty l)) rest
genRpcServerType :: [Text] -> RpcDef -> Doc ann
genRpcServerType _ rpc = case (rpcInputStr rpc, rpcOutputStr rpc) of
(NoStream, NoStream) ->
pretty (hsTypeName' (rpcInput rpc)) <+> txt "-> m" <+> pretty (hsTypeName' (rpcOutput rpc))
(Streaming, NoStream) ->
txt "m "
<> pretty (hsTypeName' (rpcInput rpc))
<+> txt "-> m"
<+> pretty (hsTypeName' (rpcOutput rpc))
(NoStream, Streaming) ->
pretty (hsTypeName' (rpcInput rpc))
<+> txt "-> ("
<> pretty (hsTypeName' (rpcOutput rpc))
<+> txt "-> m ()) -> m ()"
(Streaming, Streaming) ->
txt "m "
<> pretty (hsTypeName' (rpcInput rpc))
<+> txt "-> ("
<> pretty (hsTypeName' (rpcOutput rpc))
<+> txt "-> m ()) -> m ()"
-- | Generate the client record type.
genClientType :: [Text] -> ServiceDef -> Doc ann
genClientType scope svc =
let tyN = svcTypeName scope svc <> "Client"
in vsep
[ txt "-- | Client stub record for @" <> pretty (svcName svc) <> txt "@."
, txt "-- Each field is a function for calling one RPC method."
, txt "data " <> pretty tyN <> txt " m = " <> pretty tyN
, indent 2 (braceFields (fmap (genClientField scope) (svcRpcs svc)))
]
genClientField :: [Text] -> RpcDef -> Doc ann
genClientField scope rpc =
let fname = lowerFirst' (snakeToCamel' (rpcName rpc))
fieldLine = pretty (escapeReserved' fname) <+> txt "::" <+> genRpcClientType scope rpc
in case rpcDoc rpc of
Nothing -> fieldLine
Just doc ->
let ls = T.lines doc
in case ls of
[] -> fieldLine
(first_ : rest) ->
vsep $
[fieldLine]
<> [indent 2 (txt "-- ^ " <> pretty first_)]
<> fmap (\l -> indent 2 (txt "-- " <> pretty l)) rest
genRpcClientType :: [Text] -> RpcDef -> Doc ann
genRpcClientType = genRpcServerType
-- | Generate method metadata.
genMethodInfos :: [Text] -> ServiceDef -> Doc ann
genMethodInfos scope svc =
let tyN = svcTypeName scope svc
in vsep
[ txt "-- | Method metadata for @" <> pretty (svcName svc) <> txt "@."
, txt "data " <> pretty tyN <> txt "Method"
, indent
2
( vsep
( zipWith
( \pfx r ->
pfx
<+> pretty (hsTypeName' (rpcName r) <> "Method")
)
seps
(svcRpcs svc)
)
)
, indent 2 (txt "deriving stock (Show, Eq, Ord, Enum, Bounded)")
, mempty
, pretty (T.toLower tyN <> "MethodName :: " :: Text) <> pretty tyN <> txt "Method -> Text"
, vsep
( fmap
( \r ->
pretty (T.toLower tyN <> "MethodName " :: Text)
<> pretty (hsTypeName' (rpcName r) <> "Method" :: Text)
<+> pretty ("= \"" :: Text)
<> pretty (rpcName r)
<> pretty ("\"" :: Text)
)
(svcRpcs svc)
)
]
where
seps = txt "=" : repeat (txt "|")
-- | Generate ProtoService metadata instance.
genServiceMeta :: [Text] -> ServiceDef -> Doc ann
genServiceMeta scope svc =
let tyN = svcTypeName scope svc
fullName = T.intercalate "." (scope <> [svcName svc])
in vsep
[ txt "-- | Fully-qualified service name: @" <> pretty fullName <> txt "@"
, pretty (T.toLower tyN <> "ServiceName :: Text" :: Text)
, pretty (T.toLower tyN <> "ServiceName = \"" :: Text) <> pretty fullName <> pretty ("\"" :: Text)
, mempty
, txt "-- | All method paths for @" <> pretty (svcName svc) <> txt "@."
, pretty (T.toLower tyN <> "MethodPaths :: [Text]" :: Text)
, pretty (T.toLower tyN <> "MethodPaths = " :: Text)
<> pretty (T.pack (show (fmap (\r -> "/" <> fullName <> "/" <> rpcName r) (svcRpcs svc))))
]
genServerTypeQ :: [Text] -> (Text -> Text) -> ServiceDef -> Doc ann
genServerTypeQ scope qualify svc =
let tyN = svcTypeName scope svc <> "Server"
in vsep
[ txt "-- | Server handler record for @" <> pretty (svcName svc) <> txt "@."
, txt "-- Each field is a handler function for one RPC method."
, txt "-- Implement all fields to create a server."
, txt "data " <> pretty tyN <> txt " m = " <> pretty tyN
, indent 2 (braceFields (fmap (genServerFieldQ scope qualify) (svcRpcs svc)))
]
genServerFieldQ :: [Text] -> (Text -> Text) -> RpcDef -> Doc ann
genServerFieldQ _scope qualify rpc =
let fname = lowerFirst' (snakeToCamel' (rpcName rpc)) <> "Handler"
fieldLine = pretty (escapeReserved' fname) <+> txt "::" <+> genRpcTypeQ qualify rpc
in case rpcDoc rpc of
Nothing -> fieldLine
Just doc ->
let ls = T.lines doc
in case ls of
[] -> fieldLine
(first_ : rest) ->
vsep $
[fieldLine]
<> [indent 2 (txt "-- ^ " <> pretty first_)]
<> fmap (\l -> indent 2 (txt "-- " <> pretty l)) rest
genRpcTypeQ :: (Text -> Text) -> RpcDef -> Doc ann
genRpcTypeQ qualify rpc = case (rpcInputStr rpc, rpcOutputStr rpc) of
(NoStream, NoStream) ->
pretty (qualify (rpcInput rpc)) <+> txt "-> m" <+> pretty (qualify (rpcOutput rpc))
(Streaming, NoStream) ->
txt "m "
<> pretty (qualify (rpcInput rpc))
<+> txt "-> m"
<+> pretty (qualify (rpcOutput rpc))
(NoStream, Streaming) ->
pretty (qualify (rpcInput rpc))
<+> txt "-> ("
<> pretty (qualify (rpcOutput rpc))
<+> txt "-> m ()) -> m ()"
(Streaming, Streaming) ->
txt "m "
<> pretty (qualify (rpcInput rpc))
<+> txt "-> ("
<> pretty (qualify (rpcOutput rpc))
<+> txt "-> m ()) -> m ()"
genClientTypeQ :: [Text] -> (Text -> Text) -> ServiceDef -> Doc ann
genClientTypeQ scope qualify svc =
let tyN = svcTypeName scope svc <> "Client"
in vsep
[ txt "-- | Client stub record for @" <> pretty (svcName svc) <> txt "@."
, txt "-- Each field is a function for calling one RPC method."
, txt "data " <> pretty tyN <> txt " m = " <> pretty tyN
, indent 2 (braceFields (fmap (genClientFieldQ scope qualify) (svcRpcs svc)))
]
genClientFieldQ :: [Text] -> (Text -> Text) -> RpcDef -> Doc ann
genClientFieldQ _scope qualify rpc =
let fname = lowerFirst' (snakeToCamel' (rpcName rpc))
fieldLine = pretty (escapeReserved' fname) <+> txt "::" <+> genRpcTypeQ qualify rpc
in case rpcDoc rpc of
Nothing -> fieldLine
Just doc ->
let ls = T.lines doc
in case ls of
[] -> fieldLine
(first_ : rest) ->
vsep $
[fieldLine]
<> [indent 2 (txt "-- ^ " <> pretty first_)]
<> fmap (\l -> indent 2 (txt "-- " <> pretty l)) rest
svcTypeName :: [Text] -> ServiceDef -> Text
svcTypeName scope svc = T.intercalate "'" (fmap hsTypeName' (scope <> [svcName svc]))
braceFields :: [Doc ann] -> Doc ann
braceFields [] = txt "{ }"
braceFields (f : fs) =
vsep (txt "{ " <> f : fmap (\x -> txt ", " <> x) fs)
<> line
<> txt "}"
hsTypeName' :: Text -> Text
hsTypeName' t = case T.uncons t of
Just (c, rest) -> T.cons (toUpper c) rest
Nothing -> t
hsModuleName' :: Text -> Text
hsModuleName' = T.intercalate (T.singleton '.') . fmap capitalize . T.splitOn (T.singleton '.')
where
capitalize t = case T.uncons t of
Just (c, rest) -> T.cons (toUpper c) rest
Nothing -> t
snakeToCamel' :: Text -> Text
snakeToCamel' t =
let parts = T.splitOn (T.singleton '_') t
in case parts of
[] -> t
(p : ps) -> T.concat (lowerFirst' p : fmap titleCase ps)
lowerFirst' :: Text -> Text
lowerFirst' s = case T.uncons s of
Just (c, rest) -> T.cons (toLower c) (T.toLower rest)
Nothing -> s
titleCase :: Text -> Text
titleCase s = case T.uncons s of
Just (c, rest) -> T.cons (toUpper c) (T.toLower rest)
Nothing -> s
escapeReserved' :: Text -> Text
escapeReserved' t
| t `elem` reserved = t <> "'"
| otherwise = t
where
reserved :: [Text]
reserved =
[ "type"
, "class"
, "data"
, "default"
, "deriving"
, "do"
, "else"
, "if"
, "import"
, "in"
, "infix"
, "infixl"
, "infixr"
, "instance"
, "let"
, "module"
, "newtype"
, "of"
, "then"
, "where"
, "case"
]