packages feed

acolyte-grpc-0.1.0.0: src/Acolyte/Grpc/Generate.hs

-- | Generate @.proto@ files from acolyte API types.
--
-- @
-- import Acolyte.Grpc.Generate
--
-- protoFile :: Text
-- protoFile = generateProto @MyAPI "mypackage" "MyService"
-- @
module Acolyte.Grpc.Generate
  ( -- * Proto generation
    generateProto
  , generateProtoFull
    -- * Message definitions
  , MessageDef (..)
  , renderMessage
  , renderFieldType
    -- * Collecting message defs from API types
  , CollectMessageDefs (..)
  , CollectTypeDef (..)
    -- * Type class
  , ApiToProtoMethods (..)
  , ProtoMethod (..)
  ) where

import Data.Kind (Type)
import Data.Text (Text)
import qualified Data.Text as T
import GHC.TypeLits (KnownSymbol, symbolVal)
import Data.Proxy (Proxy (..))

import Acolyte.Core.Endpoint (Endpoint, NoBody)
import Acolyte.Core.Path (PathSegment(..))
import Acolyte.Core.Effect (Requires)
import Acolyte.Core.Wrapper
  ( Describe, WithParams, WithHeaders
  , ServerStream, ClientStream, BidiStream, RespondsWith
  )
import Acolyte.Grpc.Proto (ProtoMessageName(..), ProtoField(..), ProtoFieldType(..))
import Acolyte.Server.Response (Json)


-- | A proto service method descriptor.
data ProtoMethod = ProtoMethod
  { pmName       :: !Text  -- ^ PascalCase method name
  , pmInputType  :: !Text  -- ^ input message type name
  , pmOutputType :: !Text  -- ^ output message type name
  } deriving (Show, Eq)


-- | A complete proto message definition (name + fields).
data MessageDef = MessageDef
  { mdName   :: !Text
  , mdFields :: ![ProtoField]
  } deriving (Show, Eq)


-- | Render a 'ProtoFieldType' to its proto3 text representation.
renderFieldType :: ProtoFieldType -> Text
renderFieldType ProtoInt32          = "int32"
renderFieldType ProtoInt64          = "int64"
renderFieldType ProtoUInt32         = "uint32"
renderFieldType ProtoUInt64         = "uint64"
renderFieldType ProtoDouble         = "double"
renderFieldType ProtoFloat          = "float"
renderFieldType ProtoBool           = "bool"
renderFieldType ProtoString         = "string"
renderFieldType ProtoBytes          = "bytes"
renderFieldType (ProtoMessage name) = name
renderFieldType (ProtoRepeated inner) = "repeated " <> renderFieldType inner
renderFieldType (ProtoOptional inner) = "optional " <> renderFieldType inner


-- | Render a 'MessageDef' to proto3 text.
--
-- @
-- message User {
--   string name = 1;
--   string email = 2;
--   int64 id = 3;
-- }
-- @
renderMessage :: MessageDef -> Text
renderMessage (MessageDef name fields) =
  T.unlines $
    [ "message " <> name <> " {" ] ++
    [ "  " <> renderFieldType (pfType f) <> " " <> pfName f <> " = " <> T.pack (show (pfNumber f)) <> ";"
    | f <- fields
    ] ++
    [ "}" ]


-- | Generate a complete @.proto@ file with explicit message definitions.
generateProtoFull
  :: forall api
   . ApiToProtoMethods api
  => Text           -- ^ package name
  -> Text           -- ^ service name
  -> [MessageDef]   -- ^ message definitions
  -> Text           -- ^ .proto file content
generateProtoFull pkg svc msgs =
  let methods = apiToProtoMethods @api
      needsEmpty = any (\m -> pmInputType m == "Empty") methods
      emptyImport = if needsEmpty
        then [ "import \"google/protobuf/empty.proto\";", "" ]
        else []
  in T.unlines $
    [ "syntax = \"proto3\";"
    , ""
    ] ++
    emptyImport ++
    [ "package " <> pkg <> ";"
    , ""
    , "service " <> svc <> " {"
    ] ++
    [ "  rpc " <> pmName m <> " (" <> pmInputType m <> ") returns (" <> pmOutputType m <> ");"
    | m <- methods
    ] ++
    [ "}"
    ] ++
    (if null msgs then []
     else "" : concatMap (\msg -> [renderMessage msg]) msgs)


-- | Generate a @.proto@ file from an API type.
--
-- @
-- generateProto @MyAPI "mypackage" "UserService"
-- @
--
-- When the API's request\/response types have 'HasProtoFields' and
-- 'ProtoMessageName' instances, full message definitions are emitted.
-- Otherwise, commented stubs are produced.
generateProto
  :: forall api
   . (ApiToProtoMethods api, CollectMessageDefs api)
  => Text     -- ^ package name
  -> Text     -- ^ service name
  -> Text     -- ^ .proto file content
generateProto pkg svc =
  let methods = apiToProtoMethods @api
      collected = collectMessageDefs @api
      needsEmpty = any (\m -> pmInputType m == "Empty") methods
      emptyImport = if needsEmpty
        then [ "import \"google/protobuf/empty.proto\";", "" ]
        else []
  in T.unlines $
    [ "syntax = \"proto3\";"
    , ""
    ] ++
    emptyImport ++
    [ "package " <> pkg <> ";"
    , ""
    , "service " <> svc <> " {"
    ] ++
    [ "  rpc " <> pmName m <> " (" <> pmInputType m <> ") returns (" <> pmOutputType m <> ");"
    | m <- methods
    ] ++
    [ "}" ] ++
    (if null collected
      then
        [ ""
        , "// Message definitions (generate from HasProtoFields or write manually):"
        ] ++
        [ "// message " <> t <> " { ... }"
        | t <- uniqueTypes methods
        ]
      else "" : concatMap (\msg -> [renderMessage msg]) (dedupMsgs collected))


-- | Deduplicate message definitions by name.
dedupMsgs :: [MessageDef] -> [MessageDef]
dedupMsgs = go []
  where
    go _    []     = []
    go seen (m:ms)
      | mdName m `elem` seen = go seen ms
      | otherwise             = m : go (mdName m : seen) ms


-- | Collect unique type names from methods.
uniqueTypes :: [ProtoMethod] -> [Text]
uniqueTypes methods =
  let allTypes = concatMap (\m -> [pmInputType m, pmOutputType m]) methods
      noDups [] = []
      noDups (x:xs)
        | x `elem` xs = noDups xs
        | x == "Empty" = noDups xs
        | otherwise    = x : noDups xs
  in noDups allTypes


-- | Walk the API type and extract proto method descriptors.
class ApiToProtoMethods (api :: [Type]) where
  apiToProtoMethods :: [ProtoMethod]

instance ApiToProtoMethods '[] where
  apiToProtoMethods = []

instance (EndpointProtoMethod e, ApiToProtoMethods rest)
  => ApiToProtoMethods (e ': rest) where
  apiToProtoMethods = endpointProtoMethod @e : apiToProtoMethods @rest


-- | Extract a proto method from a single endpoint type.
class EndpointProtoMethod (e :: Type) where
  endpointProtoMethod :: ProtoMethod

instance EndpointProtoMethod inner => EndpointProtoMethod (Requires eff inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (Describe desc inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (WithParams ps inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (WithHeaders hs inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (ServerStream inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (ClientStream inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (BidiStream inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance EndpointProtoMethod inner => EndpointProtoMethod (RespondsWith s inner) where
  endpointProtoMethod = endpointProtoMethod @inner

instance {-# OVERLAPPING #-} (PathToMethodName path, ResponseTypeName resp)
  => EndpointProtoMethod (Endpoint m path NoBody resp) where
  endpointProtoMethod = ProtoMethod
    { pmName       = pathToMethodName @path
    , pmInputType  = "Empty"
    , pmOutputType = responseTypeName @resp
    }

instance (PathToMethodName path, RequestTypeName req, ResponseTypeName resp)
  => EndpointProtoMethod (Endpoint m path req resp) where
  endpointProtoMethod = ProtoMethod
    { pmName       = pathToMethodName @path
    , pmInputType  = requestTypeName @req
    , pmOutputType = responseTypeName @resp
    }


-- | Get the proto type name for a response type.
class ResponseTypeName (a :: Type) where
  responseTypeName :: Text

instance ProtoMessageName a => ResponseTypeName (Json a) where
  responseTypeName = protoMessageName @a

instance {-# OVERLAPPABLE #-} ProtoMessageName a => ResponseTypeName a where
  responseTypeName = protoMessageName @a


-- | Get the proto type name for a request type.
class RequestTypeName (a :: Type) where
  requestTypeName :: Text

instance ProtoMessageName a => RequestTypeName (Json a) where
  requestTypeName = protoMessageName @a

instance {-# OVERLAPPABLE #-} ProtoMessageName a => RequestTypeName a where
  requestTypeName = protoMessageName @a


-- | Convert a type-level path to a PascalCase gRPC method name.
class PathToMethodName (path :: [PathSegment]) where
  pathToMethodName :: Text

instance PathToMethodName '[] where
  pathToMethodName = "Unknown"

instance (KnownSymbol s, PathToMethodName rest)
  => PathToMethodName ('Lit s ': rest) where
  pathToMethodName = capitalize (T.pack (symbolVal (Proxy @s)))

instance PathToMethodName rest
  => PathToMethodName ('Capture t ': rest) where
  pathToMethodName = pathToMethodName @rest


capitalize :: Text -> Text
capitalize t = case T.uncons t of
  Just (c, rest) -> T.cons (toUpper c) rest
  Nothing        -> t
  where
    toUpper c
      | c >= 'a' && c <= 'z' = toEnum (fromEnum c - 32)
      | otherwise             = c


-- ===================================================================
-- CollectMessageDefs: walk API and gather MessageDefs
-- ===================================================================

-- | Walk an API type list and collect 'MessageDef' for every
-- request\/response type that has both 'ProtoMessageName' and
-- 'HasProtoFields' instances.
class CollectMessageDefs (api :: [Type]) where
  collectMessageDefs :: [MessageDef]

instance CollectMessageDefs '[] where
  collectMessageDefs = []

instance (CollectEndpointDefs e, CollectMessageDefs rest)
  => CollectMessageDefs (e ': rest) where
  collectMessageDefs = collectEndpointDefs @e ++ collectMessageDefs @rest


-- | Collect message defs from a single endpoint.
class CollectEndpointDefs (e :: Type) where
  collectEndpointDefs :: [MessageDef]

instance CollectEndpointDefs inner => CollectEndpointDefs (Requires eff inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (Describe desc inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (WithParams ps inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (WithHeaders hs inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (ServerStream inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (ClientStream inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (BidiStream inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance CollectEndpointDefs inner => CollectEndpointDefs (RespondsWith s inner) where
  collectEndpointDefs = collectEndpointDefs @inner

instance {-# OVERLAPPING #-} (CollectTypeDef resp)
  => CollectEndpointDefs (Endpoint m path NoBody resp) where
  collectEndpointDefs = collectTypeDef @resp

instance (CollectTypeDef req, CollectTypeDef resp)
  => CollectEndpointDefs (Endpoint m path req resp) where
  collectEndpointDefs = collectTypeDef @req ++ collectTypeDef @resp


-- | Collect a 'MessageDef' from a single type, if it has the
-- required instances. The base instance returns @[]@.
class CollectTypeDef (a :: Type) where
  collectTypeDef :: [MessageDef]

-- | Json wrapper: delegate to inner type.
instance CollectTypeDef inner => CollectTypeDef (Json inner) where
  collectTypeDef = collectTypeDef @inner

-- | Default: no message def available.
instance {-# OVERLAPPABLE #-} CollectTypeDef a where
  collectTypeDef = []

-- | Types with both 'ProtoMessageName' and 'HasProtoFields' produce
-- a 'MessageDef'. Users opt in by providing an overlapping instance.
--
-- To collect message defs for your type, add:
--
-- @
-- instance {-# OVERLAPPING #-} CollectTypeDef MyType where
--   collectTypeDef = [MessageDef (protoMessageName @MyType) (protoFields @MyType)]
-- @