packages feed

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

-- | Protobuf compatibility types and compile-time validation.
--
-- 'ToProtoType' declares that a Haskell type can be serialized to
-- protobuf wire format. 'GrpcReady' checks at compile time that
-- every request/response type in the API has this capability.
--
-- Serialization is pluggable — implement 'ToProtoType' with
-- proto-lens, binary, manual encoding, or any codec. The gRPC
-- layer only needs 'encode' and 'decode' on 'ByteString'.
module Acolyte.Grpc.Proto
  ( -- * Serialization class
    GrpcCodec (..)
    -- * Proto type metadata
  , ProtoMessageName (..)
    -- * Compile-time validation
  , GrpcReady
  , AllGrpcReady
    -- * Proto field metadata (for .proto generation)
  , ProtoField (..)
  , ProtoFieldType (..)
  , HasProtoFields (..)
  ) where

import Data.ByteString (ByteString)
import Data.Kind (Type, Constraint)
import Data.Text (Text)
import GHC.TypeLits (TypeError, ErrorMessage (..))

import qualified Spire.Protobuf

import Acolyte.Core.Endpoint (Endpoint, NoBody)
import Acolyte.Core.Effect (Requires)
import Acolyte.Server.Response (Json)


-- | Encode and decode values to/from protobuf-compatible bytes.
--
-- This is deliberately minimal — no proto-lens dependency, no
-- code generation requirement. Implement it however you like:
--
-- @
-- instance GrpcCodec MyMessage where
--   grpcEncode msg = ... -- your serialization
--   grpcDecode bs  = ... -- your deserialization
--
-- -- With proto-lens:
-- instance GrpcCodec MyProtoMessage where
--   grpcEncode = encodeMessage
--   grpcDecode = decodeMessage
--
-- -- With aeson (for grpc+json):
-- instance GrpcCodec MyType where
--   grpcEncode = LBS.toStrict . Aeson.encode
--   grpcDecode = Aeson.decodeStrict'
-- @
class GrpcCodec a where
  grpcEncode :: a -> ByteString
  grpcDecode :: ByteString -> Maybe a

-- | Any 'Spire.Protobuf.ProtoMessage' automatically gets 'GrpcCodec'.
-- Users who derive 'ProtoMessage' get gRPC for free -- no separate
-- 'GrpcCodec' instance needed.
instance {-# OVERLAPPABLE #-} Spire.Protobuf.ProtoMessage a => GrpcCodec a where
  grpcEncode = Spire.Protobuf.encode
  grpcDecode = either (const Nothing) Just . Spire.Protobuf.decode


-- | The protobuf message name for .proto generation.
--
-- @
-- instance ProtoMessageName User where
--   protoMessageName = "User"
-- @
class ProtoMessageName a where
  protoMessageName :: Text


-- | A field in a proto message (for .proto file generation).
data ProtoField = ProtoField
  { pfName   :: !Text
  , pfNumber :: !Int
  , pfType   :: !ProtoFieldType
  } deriving (Show, Eq)


-- | Proto scalar types.
data ProtoFieldType
  = ProtoInt32
  | ProtoInt64
  | ProtoUInt32
  | ProtoUInt64
  | ProtoDouble
  | ProtoFloat
  | ProtoBool
  | ProtoString
  | ProtoBytes
  | ProtoMessage !Text   -- ^ nested message by name
  | ProtoRepeated !ProtoFieldType
  | ProtoOptional !ProtoFieldType
  deriving (Show, Eq)


-- | Declare the fields of a proto message (for .proto generation).
class HasProtoFields a where
  protoFields :: [ProtoField]


-- ===================================================================
-- Compile-time validation: GrpcReady
-- ===================================================================

-- | Check that all request and response types in an API support
-- gRPC serialization.
--
-- Missing a 'GrpcCodec' instance? The compiler will report:
--
-- @
-- No instance for 'GrpcCodec MyType'
--   arising from a use of 'mkGrpcServiceMap'
-- @
--
-- To fix, add: @instance GrpcCodec MyType where ...@
--
-- Usage:
--
-- @
-- type MyAPI = '[ Get HealthPath Text
--               , Post UsersPath (Json CreateUser) (Json User)
--               ]
--
-- -- This compiles only if Text, CreateUser, and User all have GrpcCodec.
-- server :: GrpcReady MyAPI => ...
-- @
type GrpcReady (api :: [Type]) = AllGrpcReady api


-- | Walk the API list and check each endpoint's types.
--
-- Each endpoint's request and response types are individually checked
-- for a 'GrpcCodec' instance. A missing instance produces:
--
-- @
-- No instance for 'GrpcCodec SomeType'
-- @
--
-- The fix is always the same: add an @instance GrpcCodec SomeType@.
type AllGrpcReady :: [Type] -> Constraint
type family AllGrpcReady (api :: [Type]) :: Constraint where
  AllGrpcReady '[] = ()

  AllGrpcReady (Endpoint m path NoBody resp ': rest) =
    ( AssertGrpcCodec resp
    , AllGrpcReady rest
    )

  AllGrpcReady (Endpoint m path req resp ': rest) =
    ( AssertGrpcCodec req
    , AssertGrpcCodec resp
    , AllGrpcReady rest
    )

  -- Requires: delegate to inner endpoint
  AllGrpcReady (Requires e inner ': rest) =
    ( AllGrpcReady '[inner]
    , AllGrpcReady rest
    )

  -- Fallback: skip unknown wrappers
  AllGrpcReady (_ ': rest) = AllGrpcReady rest


-- | Assert that a type has a GrpcCodec instance.
type AssertGrpcCodec :: Type -> Constraint
type family AssertGrpcCodec (a :: Type) :: Constraint where
  -- Json wrapper: check the inner type
  AssertGrpcCodec (Json a) = GrpcCodec a
  -- Bare types: check directly
  AssertGrpcCodec a = GrpcCodec a


-- Json imported from Acolyte.Server.Response