packages feed

spire-grpc (empty) → 0.1.0.0

raw patch · 16 files changed

+2907/−0 lines, 16 filesdep +basedep +bytestringdep +case-insensitive

Dependencies added: base, bytestring, case-insensitive, containers, hedgehog, http-client, http-core, http-types, spire, spire-grpc, spire-server, text, zlib

Files

+ CHANGELOG.md view
@@ -0,0 +1,7 @@+# Revision history for spire-grpc++## 0.1.0.0 -- 2026-04-27++* Initial release. gRPC-over-HTTP/2 codec and runtime for spire,+  including length-prefixed message framing, gzip compression, status+  codes, and integration with spire-protobuf.
+ LICENSE view
@@ -0,0 +1,27 @@+Copyright (c) 2024-2026, Josh Burgess++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. 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.++3. Neither the name of the copyright holder nor the names of its+   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 HOLDER 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.
+ spire-grpc.cabal view
@@ -0,0 +1,119 @@+cabal-version:   3.0+name:            spire-grpc+version:         0.1.0.0+synopsis:        gRPC wire protocol for spire services+category:        Network+description:+  gRPC framing, status codes, and request/response handling built on+  spire + http-core. Provides the wire protocol layer: service/method+  dispatch, length-prefixed message framing, trailers-based status.+  .+  No protobuf dependency: serialization is pluggable. Use with+  proto-lens, binary, aeson, or any codec.+  .+  No WAI dependency: runs on spire-server's HTTP/2 transport.++license:         BSD-3-Clause+license-file:    LICENSE+author:          Josh Burgess+maintainer:      Josh Burgess <joshburgess.webdev@gmail.com>+homepage:        https://github.com/joshburgess/acolyte+bug-reports:     https://github.com/joshburgess/acolyte/issues+build-type:      Simple++extra-doc-files:+  CHANGELOG.md++library+  exposed-modules:+    Spire.Grpc+    Spire.Grpc.Status+    Spire.Grpc.Codec+    Spire.Grpc.Server+    Spire.Grpc.Multiplex+    Spire.Grpc.Reflection+    Spire.Grpc.Health+    Spire.Grpc.Compression+    Spire.Grpc.Web+    Spire.Grpc.ErrorDetails++  build-depends:+      base               >= 4.20 && < 5+    , spire              >= 0.1  && < 0.2+    , http-core          >= 0.1  && < 0.2+    , bytestring         >= 0.11 && < 0.13+    , text               >= 2.0  && < 2.2+    , http-types         >= 0.12 && < 0.13+    , case-insensitive   >= 1.2  && < 1.3+    , containers         >= 0.6  && < 0.8+    , zlib               >= 0.6  && < 0.8++  hs-source-dirs: src+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData++  ghc-options: -Wall -funbox-strict-fields++test-suite spec+  type: exitcode-stdio-1.0+  main-is: Main.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData++  ghc-options: -Wall -rtsopts "-with-rtsopts=-K1K"++  build-depends:+      base              >= 4.20 && < 5+    , spire-grpc        >= 0.1  && < 0.2+    , spire             >= 0.1  && < 0.2+    , http-core         >= 0.1  && < 0.2+    , bytestring        >= 0.11 && < 0.13+    , case-insensitive  >= 1.2  && < 1.3+    , text              >= 2.0  && < 2.2+    , http-types        >= 0.12 && < 0.13++test-suite properties+  type: exitcode-stdio-1.0+  main-is: Properties.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData+  ghc-options: -Wall+  build-depends:+      base         >= 4.20 && < 5+    , spire-grpc   >= 0.1  && < 0.2+    , hedgehog     >= 1.4  && < 1.8+    , bytestring   >= 0.11 && < 0.13+    , text         >= 2.0  && < 2.2++test-suite integration+  type: exitcode-stdio-1.0+  main-is: Integration.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    OverloadedStrings+    StrictData+  ghc-options: -Wall -threaded++  build-depends:+      base         >= 4.20 && < 5+    , spire-grpc   >= 0.1  && < 0.2+    , spire        >= 0.1  && < 0.2+    , http-core    >= 0.1  && < 0.2+    , spire-server >= 0.1  && < 0.2+    , bytestring   >= 0.11 && < 0.13+    , http-types   >= 0.12 && < 0.13+    , http-client      >= 0.7  && < 0.8+    , case-insensitive >= 1.2  && < 1.3++source-repository head+  type:     git+  location: https://github.com/joshburgess/acolyte.git
+ src/Spire/Grpc.hs view
@@ -0,0 +1,109 @@+-- | @spire-grpc@ — gRPC wire protocol for spire services.+--+-- Provides gRPC framing, status codes, and server dispatch built on+-- spire + http-core. Serialization is pluggable — use proto-lens,+-- binary, aeson, or any codec.+--+-- @+-- import Spire.Grpc+-- import Spire.Server.H2 (runServerH2, defaultH2Config)+--+-- main :: IO ()+-- main = do+--   let services = grpcServiceMap+--         [ ("greet.Greeter", "SayHello", unaryHandler sayHello)+--         ]+--   runServerH2 (defaultH2Config 50051) (grpcServer services)+-- @+module Spire.Grpc+  ( -- * Server+    grpcServer+  , GrpcServiceMap+  , grpcServiceMap+  , GrpcHandler (..)+  , GrpcRequest (..)+  , GrpcResponse (..)++    -- * Handler constructors+  , unaryHandler+  , serverStreamHandler+  , clientStreamHandler+  , bidiStreamHandler++    -- * Status codes+  , GrpcStatus (..)+  , grpcOk+  , grpcCancelled+  , grpcUnknown+  , grpcInvalidArgument+  , grpcDeadlineExceeded+  , grpcNotFound+  , grpcAlreadyExists+  , grpcPermissionDenied+  , grpcResourceExhausted+  , grpcFailedPrecondition+  , grpcAborted+  , grpcOutOfRange+  , grpcUnimplemented+  , grpcInternal+  , grpcUnavailable+  , grpcDataLoss+  , grpcUnauthenticated+  , grpcStatusCode+  , statusFromCode++    -- * Multiplexing (REST + gRPC on same port)+  , multiplex++    -- * Codec (low-level framing)+  , encodeMessage+  , encodeMessages+  , decodeMessage+  , decodeMessages+  , GrpcMessage (..)++    -- * Server reflection+  , reflectionService+  , withReflection+  , ServiceInfo (..)+  , MethodInfo (..)++    -- * Health check+  , healthService+  , withHealthCheck+  , HealthStatus (..)++    -- * Compression+  , compressMessage+  , decompressMessage+  , maxDecompressedSize+  , GrpcCompression (..)+  , encodeMessageCompressed++    -- * gRPC-Web+  , grpcWebLayer++    -- * Rich error details+  , RichGrpcStatus (..)+  , richError+  , ErrorDetail (..)+  , BadRequest (..)+  , FieldViolation (..)+  , DebugInfo (..)+  , RetryInfo (..)+  , Help (..)+  , HelpLink (..)+  , ResourceInfo (..)+  , richErrorResponse+  , encodeDetails+  ) where++import Spire.Grpc.Status+import Spire.Grpc.Codec+import Spire.Grpc.Server+import Spire.Grpc.Multiplex+import Spire.Grpc.Reflection+import Spire.Grpc.Health+import Spire.Grpc.Compression+import Spire.Grpc.Web+import Spire.Grpc.ErrorDetails
+ src/Spire/Grpc/Codec.hs view
@@ -0,0 +1,100 @@+{-# LANGUAGE OverloadedStrings #-}+-- | gRPC length-prefixed message framing.+--+-- The gRPC wire format wraps each message in a 5-byte header:+--+-- @+-- +----------+----------------+--------------------------------------++-- | 1 byte   | 4 bytes        | Message-Length bytes                  |+-- | comp flag| msg len (BE)   | message payload                      |+-- +----------+----------------+--------------------------------------++-- @+--+-- This module provides encoding and decoding of this framing format,+-- independent of any serialization library (protobuf, JSON, etc.).+module Spire.Grpc.Codec+  ( -- * Encoding+    encodeMessage+  , encodeMessages+    -- * Decoding+  , decodeMessage+  , decodeMessages+    -- * Types+  , GrpcMessage (..)+    -- * Internal (re-exported by Compression)+  , decodeWord32BE+  ) where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as Builder+import qualified Data.ByteString.Builder.Extra as BuilderEx+import qualified Data.ByteString.Lazy as LBS+import Data.Word (Word32)+import Data.Bits (shiftL)+++-- | A decoded gRPC message with its compression flag.+data GrpcMessage = GrpcMessage+  { gmCompressed :: !Bool+  , gmPayload    :: !ByteString+  } deriving (Show, Eq)+++-- | Encode a single uncompressed message into gRPC wire format.+--+-- Prepends the 5-byte header: 0x00 (uncompressed) + big-endian length.+encodeMessage :: ByteString -> ByteString+encodeMessage payload =+  let len = BS.length payload+      -- 5-byte header + payload, single allocation for typical messages+  in LBS.toStrict+     $ BuilderEx.toLazyByteStringWith+         (BuilderEx.safeStrategy (5 + len) 128)+         LBS.empty+     $ Builder.word8 0x00+       <> Builder.word32BE (fromIntegral len)+       <> Builder.byteString payload+++-- | Encode multiple messages into a single gRPC wire-format ByteString.+encodeMessages :: [ByteString] -> ByteString+encodeMessages = BS.concat . map encodeMessage+++-- | Decode the first gRPC message from a ByteString.+--+-- Returns the decoded message and any remaining bytes, or 'Nothing'+-- if the input is incomplete.+decodeMessage :: ByteString -> Maybe (GrpcMessage, ByteString)+decodeMessage bs+  | BS.length bs < 5 = Nothing+  | otherwise =+    let compressed = BS.index bs 0 /= 0+        len = fromIntegral (decodeWord32BE (BS.take 4 (BS.drop 1 bs)))+        rest = BS.drop 5 bs+    in if BS.length rest < len+       then Nothing+       else Just+         ( GrpcMessage compressed (BS.take len rest)+         , BS.drop len rest+         )+++-- | Decode all gRPC messages from a ByteString.+decodeMessages :: ByteString -> [GrpcMessage]+decodeMessages bs+  | BS.null bs = []+  | otherwise = case decodeMessage bs of+      Nothing        -> []+      Just (msg, rest) -> msg : decodeMessages rest+++-- | Decode a big-endian Word32 from 4 bytes.+decodeWord32BE :: ByteString -> Word32+decodeWord32BE bs =+  let b0 = fromIntegral (BS.index bs 0) :: Word32+      b1 = fromIntegral (BS.index bs 1) :: Word32+      b2 = fromIntegral (BS.index bs 2) :: Word32+      b3 = fromIntegral (BS.index bs 3) :: Word32+  in (b0 `shiftL` 24) + (b1 `shiftL` 16) + (b2 `shiftL` 8) + b3
+ src/Spire/Grpc/Compression.hs view
@@ -0,0 +1,111 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE BangPatterns #-}+-- | gRPC message compression support.+--+-- Implements gzip compression/decompression for gRPC messages.+-- The gRPC wire format uses a 1-byte compression flag in the+-- 5-byte message header to indicate whether the payload is compressed.+--+-- When the compressed flag is 1, the payload is gzip-compressed.+-- The @grpc-encoding@ header indicates the compression algorithm.+module Spire.Grpc.Compression+  ( -- * Compression types+    GrpcCompression (..)+    -- * Compress / decompress payloads+  , compressMessage+  , decompressMessage+    -- * Size limits+  , maxDecompressedSize+    -- * gRPC framing with compression+  , encodeMessageCompressed+    -- * Body-level helpers+  , decompressGrpcBody+  ) where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Builder as Builder+import qualified Data.ByteString.Builder.Extra as BuilderEx+import qualified Codec.Compression.GZip as GZip+import Data.Text (Text)++import Spire.Grpc.Codec (GrpcMessage (..), decodeMessages)+++-- | Compression algorithm for gRPC messages.+data GrpcCompression = NoCompression | Gzip+  deriving (Show, Eq)+++-- | Compress a payload with gzip.+compressMessage :: ByteString -> ByteString+compressMessage = LBS.toStrict . GZip.compress . LBS.fromStrict+++-- | Maximum allowed size for a decompressed gRPC message (4 MiB).+-- Protects against compression bomb attacks.+maxDecompressedSize :: Int+maxDecompressedSize = 4 * 1024 * 1024++-- | Decompress a gzip-compressed payload with size limit protection.+--+-- Uses incremental chunk consumption to avoid fully decompressing+-- a compression bomb before detecting the oversize condition.+decompressMessage :: ByteString -> Either Text ByteString+decompressMessage bs =+  let lazy = GZip.decompress (LBS.fromStrict bs)+      chunks = LBS.toChunks lazy+  in collectBounded maxDecompressedSize chunks++-- | Collect strict ByteString chunks up to a size limit.+collectBounded :: Int -> [ByteString] -> Either Text ByteString+collectBounded limit = go 0 []+  where+    go !_total !acc [] = Right (BS.concat (reverse acc))+    go !total !acc (c:cs)+      | total + BS.length c > limit = Left "Decompressed message exceeds size limit"+      | otherwise = go (total + BS.length c) (c:acc) cs+++-- | Encode a single message into gRPC wire format with gzip compression.+--+-- Sets the compressed flag to 1 and gzip-compresses the payload.+encodeMessageCompressed :: ByteString -> ByteString+encodeMessageCompressed payload =+  let compressed = compressMessage payload+      len = BS.length compressed+  in LBS.toStrict+     $ BuilderEx.toLazyByteStringWith+         (BuilderEx.safeStrategy (5 + len) 128)+         LBS.empty+     $ Builder.word8 0x01+       <> Builder.word32BE (fromIntegral len)+       <> Builder.byteString compressed+++-- | Decompress all compressed messages in a gRPC body.+--+-- Decodes all length-prefixed messages, decompresses any that have+-- the compressed flag set, and re-encodes them as uncompressed messages.+-- This allows the rest of the pipeline to work with uncompressed data.+-- Returns 'Left' if any message exceeds the decompression size limit.+decompressGrpcBody :: ByteString -> Either Text ByteString+decompressGrpcBody body =+  let msgs = decodeMessages body+      decompress msg+        | gmCompressed msg = decompressMessage (gmPayload msg)+        | otherwise        = Right (gmPayload msg)+      -- Re-encode as uncompressed framed messages+      reEncode payload =+        let len = BS.length payload+        in LBS.toStrict+           $ BuilderEx.toLazyByteStringWith+               (BuilderEx.safeStrategy (5 + len) 128)+               LBS.empty+           $ Builder.word8 0x00+             <> Builder.word32BE (fromIntegral len)+             <> Builder.byteString payload+  in case mapM decompress msgs of+    Left err     -> Left err+    Right chunks -> Right (BS.concat (map reEncode chunks))
+ src/Spire/Grpc/ErrorDetails.hs view
@@ -0,0 +1,204 @@+-- | Rich gRPC error details.+--+-- gRPC supports structured error details beyond a simple status code and+-- message. This module provides types for common error detail kinds+-- (bad request, debug info, retry info, help, resource info) and a way+-- to encode them into a 'GrpcResponse'.+--+-- Details are encoded as simple JSON in the @grpc-status-details-bin@+-- trailer, keeping the implementation dependency-free (no protobuf).+--+-- @+-- import Spire.Grpc.ErrorDetails+--+-- handler :: ByteString -> IO (Either GrpcStatus ByteString)+-- handler req = pure $ Left $ rsStatus $ richError (grpcInvalidArgument "bad input")+--   [ DetailBadRequest $ BadRequest+--       [ FieldViolation "name" "must not be empty"+--       , FieldViolation "age" "must be positive"+--       ]+--   ]+-- @+module Spire.Grpc.ErrorDetails+  ( -- * Rich error status+    RichGrpcStatus (..)+  , richError+    -- * Error detail types+  , ErrorDetail (..)+  , BadRequest (..)+  , FieldViolation (..)+  , DebugInfo (..)+  , RetryInfo (..)+  , Help (..)+  , HelpLink (..)+  , ResourceInfo (..)+    -- * Converting to GrpcResponse+  , richErrorResponse+    -- * Encoding+  , encodeDetails+  ) where++import Data.Text (Text)+import qualified Data.Text as T++import Spire.Grpc.Status (GrpcStatus (..))+import Spire.Grpc.Server (GrpcResponse (..))+++-- | A gRPC status enriched with structured error details.+data RichGrpcStatus = RichGrpcStatus+  { rsStatus  :: !GrpcStatus+  , rsDetails :: ![ErrorDetail]+  } deriving (Show, Eq)+++-- | Create a 'RichGrpcStatus'.+richError :: GrpcStatus -> [ErrorDetail] -> RichGrpcStatus+richError = RichGrpcStatus+++-- | A structured error detail.+data ErrorDetail+  = DetailBadRequest !BadRequest+  | DetailDebugInfo !DebugInfo+  | DetailRetryInfo !RetryInfo+  | DetailHelp !Help+  | DetailResource !ResourceInfo+  deriving (Show, Eq)+++-- | Describes violations in a client request.+data BadRequest = BadRequest+  { brFieldViolations :: ![FieldViolation]+  } deriving (Show, Eq)+++-- | A single field violation within a 'BadRequest'.+data FieldViolation = FieldViolation+  { fvField       :: !Text+  , fvDescription :: !Text+  } deriving (Show, Eq)+++-- | Debug information (stack traces, diagnostic messages).+data DebugInfo = DebugInfo+  { diStackEntries :: ![Text]+  , diDetail       :: !Text+  } deriving (Show, Eq)+++-- | Retry information with a delay hint.+data RetryInfo = RetryInfo+  { riRetryDelay :: !Int  -- ^ Suggested retry delay in milliseconds+  } deriving (Show, Eq)+++-- | Help links for the client.+data Help = Help+  { hLinks :: ![HelpLink]+  } deriving (Show, Eq)+++-- | A single help link.+data HelpLink = HelpLink+  { hlDescription :: !Text+  , hlUrl         :: !Text+  } deriving (Show, Eq)+++-- | Information about the resource involved in the error.+data ResourceInfo = ResourceInfo+  { riResourceType :: !Text+  , riResourceName :: !Text+  , riOwner        :: !Text+  , riDescription  :: !Text+  } deriving (Show, Eq)+++-- | Convert a 'RichGrpcStatus' to a 'GrpcResponse'.+--+-- The error details are JSON-encoded and placed in the @grpc-message@+-- field, providing structured error information to clients that parse it.+-- The status code and human-readable summary come from 'rsStatus'.+richErrorResponse :: RichGrpcStatus -> GrpcResponse+richErrorResponse rich =+  let status = rsStatus rich+      details = rsDetails rich+      detailsJson = encodeDetails details+      -- Append details JSON to the message if there are details+      enrichedStatus = if null details+        then status+        else status { gsMessage = gsMessage status <> " [details:" <> detailsJson <> "]" }+  in GrpcError enrichedStatus+++-- | Encode error details as a simple JSON string.+--+-- Uses a dependency-free JSON encoding (no aeson). The format is:+--+-- @+-- [{"@type":"BadRequest","field_violations":[{"field":"name","description":"required"}]}, ...]+-- @+encodeDetails :: [ErrorDetail] -> Text+encodeDetails details =+  "[" <> T.intercalate "," (map encodeDetail details) <> "]"+++-- | Encode a single error detail as JSON.+encodeDetail :: ErrorDetail -> Text+encodeDetail (DetailBadRequest br) =+  "{\"@type\":\"BadRequest\",\"field_violations\":["+  <> T.intercalate "," (map encodeFieldViolation (brFieldViolations br))+  <> "]}"++encodeDetail (DetailDebugInfo di) =+  "{\"@type\":\"DebugInfo\",\"stack_entries\":["+  <> T.intercalate "," (map jsonString (diStackEntries di))+  <> "],\"detail\":" <> jsonString (diDetail di) <> "}"++encodeDetail (DetailRetryInfo ri) =+  "{\"@type\":\"RetryInfo\",\"retry_delay_ms\":" <> T.pack (show (riRetryDelay ri)) <> "}"++encodeDetail (DetailHelp h) =+  "{\"@type\":\"Help\",\"links\":["+  <> T.intercalate "," (map encodeHelpLink (hLinks h))+  <> "]}"++encodeDetail (DetailResource ri) =+  "{\"@type\":\"ResourceInfo\""+  <> ",\"resource_type\":" <> jsonString (riResourceType ri)+  <> ",\"resource_name\":" <> jsonString (riResourceName ri)+  <> ",\"owner\":" <> jsonString (riOwner ri)+  <> ",\"description\":" <> jsonString (riDescription ri)+  <> "}"+++-- | Encode a field violation as JSON.+encodeFieldViolation :: FieldViolation -> Text+encodeFieldViolation fv =+  "{\"field\":" <> jsonString (fvField fv)+  <> ",\"description\":" <> jsonString (fvDescription fv) <> "}"+++-- | Encode a help link as JSON.+encodeHelpLink :: HelpLink -> Text+encodeHelpLink hl =+  "{\"description\":" <> jsonString (hlDescription hl)+  <> ",\"url\":" <> jsonString (hlUrl hl) <> "}"+++-- | Encode a Text value as a JSON string with basic escaping.+jsonString :: Text -> Text+jsonString t = "\"" <> escapeJson t <> "\""+++-- | Escape special characters for JSON.+escapeJson :: Text -> Text+escapeJson = T.concatMap escapeChar+  where+    escapeChar '"'  = "\\\""+    escapeChar '\\' = "\\\\"+    escapeChar '\n' = "\\n"+    escapeChar '\r' = "\\r"+    escapeChar '\t' = "\\t"+    escapeChar c    = T.singleton c
+ src/Spire/Grpc/Health.hs view
@@ -0,0 +1,76 @@+{-# LANGUAGE OverloadedStrings #-}+-- | gRPC health check service.+--+-- Implements the @grpc.health.v1.Health/Check@ protocol.+-- See <https://github.com/grpc/grpc/blob/master/doc/health-checking.md>.+--+-- For simplicity, the health status is encoded as a single byte:+--+--   * 0 = UNKNOWN+--   * 1 = SERVING+--   * 2 = NOT_SERVING+--+-- @+-- import Spire.Grpc+-- import Spire.Grpc.Health+--+-- main :: IO ()+-- main = do+--   let services = withHealthCheck (pure Serving) $ grpcServiceMap+--         [ ("myapp.Greeter", "SayHello", unaryHandler sayHello)+--         ]+--   runServerH2 (defaultH2Config 50051) (grpcServer services)+-- @+module Spire.Grpc.Health+  ( -- * Health status+    HealthStatus (..)+    -- * Service construction+  , healthService+  , withHealthCheck+  ) where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.Map.Strict as Map++import Spire.Grpc.Status+import Spire.Grpc.Server (GrpcHandler (..), GrpcResponse (..), GrpcServiceMap)+++-- | Health status of a service.+data HealthStatus = Serving | NotServing | Unknown+  deriving (Show, Eq)+++-- | Encode a 'HealthStatus' as a single byte.+encodeStatus :: HealthStatus -> ByteString+encodeStatus Unknown    = BS.singleton 0+encodeStatus Serving    = BS.singleton 1+encodeStatus NotServing = BS.singleton 2+++-- | Build a health check service map.+--+-- Implements @grpc.health.v1.Health/Check@. The provided action is+-- called on each health check request to get the current status.+healthService :: IO HealthStatus -> GrpcServiceMap+healthService getStatus = Map.fromList+  [ (("grpc.health.v1.Health", "Check"), healthCheckHandler getStatus)+  ]+++-- | Add health check to an existing service map.+--+-- Merges the health check service into the given map.+-- If the map already contains a @grpc.health.v1.Health/Check@ entry,+-- it will be overwritten.+withHealthCheck :: IO HealthStatus -> GrpcServiceMap -> GrpcServiceMap+withHealthCheck getStatus existing =+  Map.union (healthService getStatus) existing+++-- | Handler for @grpc.health.v1.Health/Check@.+healthCheckHandler :: IO HealthStatus -> GrpcHandler+healthCheckHandler getStatus = GrpcHandler $ \_req -> do+  status <- getStatus+  pure $ GrpcUnary (grpcOk "") (encodeStatus status)
+ src/Spire/Grpc/Multiplex.hs view
@@ -0,0 +1,40 @@+{-# LANGUAGE OverloadedStrings #-}+-- | REST+gRPC multiplexing — serve both on the same port.+--+-- Dispatches based on the @Content-Type@ header:+--+-- * @application\/grpc*@ → gRPC service+-- * everything else → REST service+--+-- @+-- main = do+--   let rest = mkServer @API restHandlers+--   let grpc = grpcServer (mkGrpcServiceMap @API "pkg" "Svc" grpcHandlers)+--   let combined = multiplex (adaptToBody rest) grpc+--   runServerH2 (defaultH2Config 8080) combined+-- @+module Spire.Grpc.Multiplex+  ( multiplex+  ) where++import qualified Data.ByteString as BS++import Spire.Service (Service (..))+import Http.Core (Request (..), Response, Body)+++-- | Combine a REST service and a gRPC service into one.+--+-- Dispatches based on the @Content-Type@ header:+--+-- * @application\/grpc*@ → gRPC service+-- * everything else → REST service+multiplex+  :: Service IO (Request Body) (Response Body)  -- ^ REST service+  -> Service IO (Request Body) (Response Body)  -- ^ gRPC service+  -> Service IO (Request Body) (Response Body)  -- ^ Combined service+multiplex (Service restHandler) (Service grpcHandler) = Service $ \req ->+  let ct = lookup "content-type" (requestHeaders req)+  in case ct of+    Just v | "application/grpc" `BS.isPrefixOf` v -> grpcHandler req+    _ -> restHandler req
+ src/Spire/Grpc/Reflection.hs view
@@ -0,0 +1,126 @@+{-# LANGUAGE OverloadedStrings #-}+-- | gRPC server reflection service.+--+-- Allows tools like @grpcurl@ to discover services at runtime without+-- requiring a @.proto@ file. Implements a simplified text-based+-- reflection protocol at the standard reflection service path.+--+-- @+-- let services = grpcServiceMap [...]+-- let infos = [ ServiceInfo "myapp.Greeter" [...] "..." ]+-- let withRefl = withReflection infos services+-- runServerH2 (defaultH2Config 50051) (grpcServer withRefl)+-- @+module Spire.Grpc.Reflection+  ( -- * Reflection service+    reflectionService+  , ServiceInfo (..)+  , MethodInfo (..)+    -- * Adding reflection to a server+  , withReflection+  ) where++import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE++import Spire.Grpc.Server (GrpcServiceMap, GrpcHandler (..), GrpcRequest (..), GrpcResponse (..))+import Spire.Grpc.Status+++-- | Description of a single RPC method.+data MethodInfo = MethodInfo+  { miName       :: !Text+  , miInputType  :: !Text+  , miOutputType :: !Text+  } deriving (Show, Eq)+++-- | Description of a gRPC service for reflection.+data ServiceInfo = ServiceInfo+  { siFullName  :: !Text       -- ^ e.g. "myapp.UserService"+  , siMethods   :: ![MethodInfo]+  , siProtoFile :: !Text       -- ^ the .proto text (generated or provided)+  } deriving (Show, Eq)+++-- | Build a 'GrpcServiceMap' containing the reflection service handlers.+reflectionService :: [ServiceInfo] -> GrpcServiceMap+reflectionService infos = reflectionHandlers infos+++-- | Add reflection to an existing 'GrpcServiceMap'.+--+-- Registers the reflection service alongside your application services.+-- The reflection service itself is also listed in service discovery.+withReflection :: [ServiceInfo] -> GrpcServiceMap -> GrpcServiceMap+withReflection infos existing = Map.union (reflectionHandlers infos) existing+++-- | Internal: build the handler map for the reflection service.+reflectionHandlers :: [ServiceInfo] -> GrpcServiceMap+reflectionHandlers infos =+  Map.fromList+    [ ( ("grpc.reflection.v1alpha.ServerReflection", "ServerReflectionInfo")+      , reflectionHandler infos+      )+    ]+++-- | The reflection handler. Implements a simple text-based protocol:+--+-- * @"list_services"@ -> newline-separated list of service full names+-- * @"file_containing_symbol:<symbol>"@ -> .proto text for that service+-- * anything else -> NOT_FOUND+reflectionHandler :: [ServiceInfo] -> GrpcHandler+reflectionHandler infos = GrpcHandler $ \req -> do+  let payload = grpcBody req+      payloadText = TE.decodeUtf8 payload+  pure $ case parseReflectionRequest payloadText of+    ListServices ->+      let names = T.intercalate "\n" (map siFullName infos)+      in GrpcUnary (grpcOk "") (TE.encodeUtf8 names)++    FileContainingSymbol symbol ->+      case findServiceBySymbol infos symbol of+        Just info ->+          GrpcUnary (grpcOk "") (TE.encodeUtf8 (siProtoFile info))+        Nothing ->+          GrpcError (grpcNotFound ("Symbol not found: " <> symbol))++    UnknownRequest ->+      GrpcError (grpcNotFound "Unknown reflection request")+++-- | Parsed reflection request types.+data ReflectionRequest+  = ListServices+  | FileContainingSymbol !Text+  | UnknownRequest+++-- | Parse the text payload into a reflection request.+parseReflectionRequest :: Text -> ReflectionRequest+parseReflectionRequest txt+  | T.strip txt == "list_services" = ListServices+  | "file_containing_symbol:" `T.isPrefixOf` txt =+      let symbol = T.strip (T.drop (T.length "file_containing_symbol:") txt)+      in FileContainingSymbol symbol+  | otherwise = UnknownRequest+++-- | Find a service whose full name matches the symbol, or whose methods+-- contain the symbol (as "ServiceName.MethodName").+findServiceBySymbol :: [ServiceInfo] -> Text -> Maybe ServiceInfo+findServiceBySymbol infos symbol =+  case filter (\si -> siFullName si == symbol) infos of+    (x:_) -> Just x+    []    ->+      -- Try matching as ServiceName.MethodName+      case filter (hasMethod symbol) infos of+        (x:_) -> Just x+        []    -> Nothing+  where+    hasMethod sym si =+      any (\mi -> siFullName si <> "." <> miName mi == sym) (siMethods si)
+ src/Spire/Grpc/Server.hs view
@@ -0,0 +1,298 @@+{-# LANGUAGE OverloadedStrings #-}+-- | gRPC server dispatch layer.+--+-- Sits between the HTTP/2 transport (spire-server) and application+-- handlers. Parses incoming gRPC requests, dispatches to the right+-- method handler, and constructs gRPC responses with trailers.+--+-- @+-- import Spire.Grpc.Server+--+-- myService :: GrpcServiceMap+-- myService = fromList+--   [ ("greet.Greeter", "SayHello", unaryHandler sayHello)+--   ]+--+-- main = runServerH2 (defaultH2Config 50051) (grpcServer myService)+-- @+module Spire.Grpc.Server+  ( -- * Service map+    GrpcServiceMap+  , GrpcHandler (..)+  , grpcServiceMap+    -- * Method handlers+  , unaryHandler+  , serverStreamHandler+  , clientStreamHandler+  , bidiStreamHandler+    -- * Server construction+  , grpcServer+    -- * Request/response types+  , GrpcRequest (..)+  , GrpcResponse (..)+  ) where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.CaseInsensitive as CI+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Network.HTTP.Types (status200, status415)++import Spire.Service (Service (..))+import Http.Core+import Http.Core.Body++import Spire.Grpc.Status as GS+import Spire.Grpc.Codec+import Spire.Grpc.Compression+++-- | A parsed gRPC request.+data GrpcRequest = GrpcRequest+  { grpcService  :: !Text       -- ^ e.g. "greet.Greeter"+  , grpcMethod   :: !Text       -- ^ e.g. "SayHello"+  , grpcBody     :: !ByteString -- ^ first message payload (after framing)+  , grpcRawBody  :: !ByteString -- ^ raw body bytes (with length-prefixed framing)+  , grpcMetadata :: ![(CI.CI ByteString, ByteString)]  -- ^ request metadata (headers)+  } deriving (Show)+++-- | A gRPC response from a handler.+data GrpcResponse+  = GrpcUnary !GrpcStatus !ByteString+    -- ^ Unary response: status + single response message payload+  | GrpcStream !GrpcStatus ![ByteString]+    -- ^ Server streaming: status + list of response message payloads+  | GrpcError !GrpcStatus+    -- ^ Error with no body (trailers-only response)+  deriving (Show)+++-- | A gRPC method handler.+newtype GrpcHandler = GrpcHandler+  { runGrpcHandler :: GrpcRequest -> IO GrpcResponse+  }+++-- | Map from (service, method) to handler.+type GrpcServiceMap = Map (Text, Text) GrpcHandler+++-- | Build a service map from a list of (service, method, handler) triples.+grpcServiceMap :: [(Text, Text, GrpcHandler)] -> GrpcServiceMap+grpcServiceMap = Map.fromList . map (\(s, m, h) -> ((s, m), h))+++-- | Create a unary handler (one request message -> one response message).+--+-- @+-- sayHello :: ByteString -> IO (Either GrpcStatus ByteString)+-- sayHello reqBytes = do+--   let name = decodeProto reqBytes  -- your deserialization+--   pure $ Right (encodeProto (HelloReply ("Hello " <> name)))+-- @+unaryHandler :: (ByteString -> IO (Either GrpcStatus ByteString)) -> GrpcHandler+unaryHandler f = GrpcHandler $ \req -> do+  result <- f (grpcBody req)+  pure $ case result of+    Right respBytes -> GrpcUnary (grpcOk "") respBytes+    Left status     -> GrpcError status+++-- | Create a server-streaming handler.+--+-- @+-- listFeatures :: ByteString -> IO (Either GrpcStatus [ByteString])+-- listFeatures reqBytes = do+--   let area = decodeProto reqBytes+--   features <- queryFeatures area+--   pure $ Right (map encodeProto features)+-- @+serverStreamHandler :: (ByteString -> IO (Either GrpcStatus [ByteString])) -> GrpcHandler+serverStreamHandler f = GrpcHandler $ \req -> do+  result <- f (grpcBody req)+  pure $ case result of+    Right chunks -> GrpcStream (grpcOk "") chunks+    Left status  -> GrpcError status+++-- | Create a client-streaming handler.+-- Receives multiple messages from the client, returns one response.+--+-- Note: This is a simplified (buffered) implementation that collects all+-- client messages before processing. True streaming would require a+-- different handler type with pull/push channels.+--+-- @+-- recordRoute :: [ByteString] -> IO (Either GrpcStatus ByteString)+-- recordRoute points = do+--   let count = length points+--   pure $ Right (encodeProto (RouteSummary count))+-- @+clientStreamHandler :: ([ByteString] -> IO (Either GrpcStatus ByteString)) -> GrpcHandler+clientStreamHandler f = GrpcHandler $ \req -> do+  -- The raw body contains multiple length-prefixed messages+  let msgs = decodeMessages (grpcRawBody req)+      payloads = map gmPayload msgs+  result <- f payloads+  pure $ case result of+    Right resp -> GrpcUnary (grpcOk "") resp+    Left status -> GrpcError status+++-- | Create a bidirectional-streaming handler.+-- Receives multiple messages, returns multiple messages.+--+-- Note: This is a simplified (buffered) implementation that collects all+-- client messages before processing. True streaming would require a+-- different handler type with pull/push channels.+--+-- @+-- routeChat :: [ByteString] -> IO (Either GrpcStatus [ByteString])+-- routeChat notes = do+--   responses <- mapM processNote notes+--   pure $ Right responses+-- @+bidiStreamHandler :: ([ByteString] -> IO (Either GrpcStatus [ByteString])) -> GrpcHandler+bidiStreamHandler f = GrpcHandler $ \req -> do+  let msgs = decodeMessages (grpcRawBody req)+      payloads = map gmPayload msgs+  result <- f payloads+  pure $ case result of+    Right resps -> GrpcStream (grpcOk "") resps+    Left status -> GrpcError status+++-- | Maximum gRPC request body size (4 MiB).+--+-- Protects against unbounded memory consumption from oversized messages.+-- Individual services can implement their own limits for finer control.+maxGrpcBodySize :: Int+maxGrpcBodySize = 4 * 1024 * 1024+++-- | Build a spire Service that dispatches gRPC requests.+--+-- Parses the gRPC path, looks up the handler in the service map,+-- frames the response with gRPC trailers.+grpcServer :: GrpcServiceMap -> Service IO (Request Body) (Response Body)+grpcServer services = Service $ \req -> do+  -- Validate content-type+  let ct = lookup "content-type" (requestHeaders req)+  case ct of+    Just v | "application/grpc" `BS.isPrefixOf` v -> do+      -- NOTE: The body is fully buffered by bodyToStrict before the size check.+      -- The transport layer (spire-server configMaxBodySize, default 2 MiB)+      -- provides the first line of defense against oversized bodies.+      -- This check is a secondary safeguard for gRPC-specific limits.+      body <- bodyToStrict (requestBody req)+      if BS.length body > maxGrpcBodySize+        then pure $ grpcTrailersOnly (GS.grpcResourceExhausted "Request body too large")+        else handleGrpc services req body+    _ ->+      pure $ Response status415 [] (fromBytes "Unsupported content type")+++-- | Handle a validated gRPC request.+handleGrpc+  :: GrpcServiceMap+  -> Request Body+  -> ByteString+  -> IO (Response Body)+handleGrpc services req body = do+  let pathRaw = requestPathRaw req+      (service, method) = parseGrpcPath (TE.decodeUtf8Lenient pathRaw)+  case Map.lookup (service, method) services of+    Nothing ->+      pure $ grpcTrailersOnly (grpcUnimplemented+        ("Method not found: " <> service <> "/" <> method))+    Just handler -> do+      -- Check if client sent compressed messages+      let encoding = lookup "grpc-encoding" (requestHeaders req)+          clientCompression = case encoding of+            Just "gzip" -> Gzip+            _           -> NoCompression+      -- Decompress the body if needed, then decode the request message+      let decompressResult = case clientCompression of+            Gzip -> decompressGrpcBody body+            NoCompression -> Right body+      case decompressResult of+        Left err ->+          pure $ grpcTrailersOnly (grpcResourceExhausted err)+        Right decompressedBody -> do+          let payload = case decodeMessage decompressedBody of+                Just (msg, _) -> gmPayload msg+                Nothing       -> decompressedBody  -- fallback: raw bytes+          let grpcReq = GrpcRequest+                { grpcService  = service+                , grpcMethod   = method+                , grpcBody     = payload+                , grpcRawBody  = decompressedBody+                , grpcMetadata = requestHeaders req+                }+          resp <- runGrpcHandler handler grpcReq+          pure $ grpcResponseToHttp resp+++-- | Parse a gRPC path like "/pkg.Service/Method" into (service, method).+parseGrpcPath :: Text -> (Text, Text)+parseGrpcPath path =+  let stripped = T.dropWhile (== '/') path+  in case T.breakOn "/" stripped of+    (service, rest) -> (service, T.drop 1 rest)+++-- | Convert a GrpcResponse to an HTTP Response with proper trailers.+--+-- NOTE: gRPC spec requires grpc-status in HTTP/2 trailers.+-- In our HTTP/1.1 path, trailers are sent as trailing headers+-- (which is non-compliant but widely accepted by clients).+-- For the HTTP/2 path, proper trailer support requires changes+-- to the H2 bridge to use responseStreamingWithTrailers.+-- For gRPC-Web, trailers are correctly encoded in the response body.+grpcResponseToHttp :: GrpcResponse -> Response Body+grpcResponseToHttp (GrpcUnary status payload) =+  let body = encodeMessage payload+      trailers = grpcTrailers status+  in Response status200+    (grpcResponseHeaders ++ trailers)+    (fromBytes body)++grpcResponseToHttp (GrpcStream status payloads) =+  let body = encodeMessages payloads+      trailers = grpcTrailers status+  in Response status200+    (grpcResponseHeaders ++ trailers)+    (fromBytes body)++grpcResponseToHttp (GrpcError status) =+  grpcTrailersOnly status+++-- | Trailers-only response (error, no body).+grpcTrailersOnly :: GrpcStatus -> Response Body+grpcTrailersOnly status =+  Response status200+    (grpcResponseHeaders ++ grpcTrailers status)+    emptyBody+++-- | Standard gRPC response headers.+grpcResponseHeaders :: [(CI.CI ByteString, ByteString)]+grpcResponseHeaders =+  [ ("content-type", "application/grpc+proto")+  ]+++-- | gRPC trailers from a status.+grpcTrailers :: GrpcStatus -> [(CI.CI ByteString, ByteString)]+grpcTrailers status =+  [ ("grpc-status", GS.grpcStatusCode status)+  ] ++ if T.null (gsMessage status)+       then []+       else [("grpc-message", TE.encodeUtf8 (gsMessage status))]
+ src/Spire/Grpc/Status.hs view
@@ -0,0 +1,120 @@+{-# LANGUAGE OverloadedStrings #-}+-- | gRPC status codes per the gRPC specification.+--+-- These are sent in the @grpc-status@ trailer, NOT in the HTTP status+-- code (which is always 200 for gRPC).+module Spire.Grpc.Status+  ( -- * Status type+    GrpcStatus (..)+    -- * Status codes+  , grpcOk+  , grpcCancelled+  , grpcUnknown+  , grpcInvalidArgument+  , grpcDeadlineExceeded+  , grpcNotFound+  , grpcAlreadyExists+  , grpcPermissionDenied+  , grpcResourceExhausted+  , grpcFailedPrecondition+  , grpcAborted+  , grpcOutOfRange+  , grpcUnimplemented+  , grpcInternal+  , grpcUnavailable+  , grpcDataLoss+  , grpcUnauthenticated+    -- * Conversion+  , grpcStatusCode+  , statusFromCode+  ) where++import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as BS8+import Data.Text (Text)+++-- | A gRPC status with code and optional message.+data GrpcStatus = GrpcStatus+  { gsCode    :: !Int+  , gsMessage :: !Text+  } deriving (Show, Eq)+++-- | Status code 0: success.+grpcOk :: Text -> GrpcStatus+grpcOk                 = GrpcStatus 0++-- | Status code 1: the operation was cancelled.+grpcCancelled :: Text -> GrpcStatus+grpcCancelled          = GrpcStatus 1++-- | Status code 2: unknown error.+grpcUnknown :: Text -> GrpcStatus+grpcUnknown            = GrpcStatus 2++-- | Status code 3: the client specified an invalid argument.+grpcInvalidArgument :: Text -> GrpcStatus+grpcInvalidArgument    = GrpcStatus 3++-- | Status code 4: deadline expired before the operation completed.+grpcDeadlineExceeded :: Text -> GrpcStatus+grpcDeadlineExceeded   = GrpcStatus 4++-- | Status code 5: requested entity was not found.+grpcNotFound :: Text -> GrpcStatus+grpcNotFound           = GrpcStatus 5++-- | Status code 6: the entity already exists.+grpcAlreadyExists :: Text -> GrpcStatus+grpcAlreadyExists      = GrpcStatus 6++-- | Status code 7: the caller does not have permission.+grpcPermissionDenied :: Text -> GrpcStatus+grpcPermissionDenied   = GrpcStatus 7++-- | Status code 8: some resource has been exhausted.+grpcResourceExhausted :: Text -> GrpcStatus+grpcResourceExhausted  = GrpcStatus 8++-- | Status code 9: the system is not in the required state.+grpcFailedPrecondition :: Text -> GrpcStatus+grpcFailedPrecondition = GrpcStatus 9++-- | Status code 10: the operation was aborted.+grpcAborted :: Text -> GrpcStatus+grpcAborted            = GrpcStatus 10++-- | Status code 11: the operation was attempted past the valid range.+grpcOutOfRange :: Text -> GrpcStatus+grpcOutOfRange         = GrpcStatus 11++-- | Status code 12: the operation is not implemented.+grpcUnimplemented :: Text -> GrpcStatus+grpcUnimplemented      = GrpcStatus 12++-- | Status code 13: internal error.+grpcInternal :: Text -> GrpcStatus+grpcInternal           = GrpcStatus 13++-- | Status code 14: the service is currently unavailable.+grpcUnavailable :: Text -> GrpcStatus+grpcUnavailable        = GrpcStatus 14++-- | Status code 15: unrecoverable data loss or corruption.+grpcDataLoss :: Text -> GrpcStatus+grpcDataLoss           = GrpcStatus 15++-- | Status code 16: the request does not have valid authentication credentials.+grpcUnauthenticated :: Text -> GrpcStatus+grpcUnauthenticated    = GrpcStatus 16+++-- | Convert a gRPC status to its numeric code as a ByteString.+grpcStatusCode :: GrpcStatus -> ByteString+grpcStatusCode = BS8.pack . show . gsCode+++-- | Parse a numeric code into a GrpcStatus (with empty message).+statusFromCode :: Int -> GrpcStatus+statusFromCode n = GrpcStatus n ""
+ src/Spire/Grpc/Web.hs view
@@ -0,0 +1,127 @@+-- | gRPC-Web protocol translation layer.+--+-- gRPC-Web allows browser clients (which cannot use HTTP/2 trailers) to+-- communicate with gRPC servers. The key differences from native gRPC:+--+-- * Content-Type: @application\/grpc-web@ or @application\/grpc-web+proto@+-- * Trailers are encoded in the response body (not HTTP/2 trailers) as a+--   1-byte flag (@0x80@) + length-prefixed trailer block appended after+--   the last message+-- * Works over HTTP/1.1 (no HTTP/2 required)+--+-- @+-- import Spire.Layer (applyLayer)+-- import Spire.Grpc.Web (grpcWebLayer)+--+-- -- Wrap your gRPC server to accept gRPC-Web clients:+-- grpcWebServer = applyLayer grpcWebLayer (grpcServer services)+-- @+module Spire.Grpc.Web+  ( grpcWebLayer+  ) where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as Builder+import qualified Data.ByteString.Builder.Extra as BuilderEx+import qualified Data.ByteString.Lazy as LBS+import qualified Data.CaseInsensitive as CI++import Spire.Layer (Middleware, around)+import Http.Core (Request (..), Response (..), Body, fromBytes, bodyToStrict)+++-- | A spire 'Middleware' that translates between gRPC-Web and native gRPC.+--+-- On request: if the content-type starts with @application\/grpc-web@,+-- rewrites it to @application\/grpc@ so the inner service sees a normal+-- gRPC request.+--+-- On response: encodes @grpc-status@ and @grpc-message@ trailers into the+-- response body as a trailer frame (flag @0x80@ + length-prefixed trailer+-- block), and sets the response content-type to @application\/grpc-web@.+--+-- Non-gRPC-Web requests pass through unmodified.+grpcWebLayer :: Middleware IO (Request Body) (Response Body)+grpcWebLayer = around $ \req callInner -> do+  let ct = lookup "content-type" (requestHeaders req)+  case ct of+    Just v | isGrpcWeb v -> do+      -- Rewrite content-type to native gRPC+      let req' = req { requestHeaders = rewriteContentType (requestHeaders req) }+      resp <- callInner req'+      -- Encode trailers into body for gRPC-Web+      encodeGrpcWebResponse resp+    _ -> callInner req+++-- | Check if a content-type value indicates gRPC-Web.+isGrpcWeb :: ByteString -> Bool+isGrpcWeb = BS.isPrefixOf "application/grpc-web"+++-- | Rewrite @application\/grpc-web*@ content-type to @application\/grpc+proto@.+rewriteContentType+  :: [(CI.CI ByteString, ByteString)]+  -> [(CI.CI ByteString, ByteString)]+rewriteContentType = map rewrite+  where+    rewrite (name, val)+      | name == "content-type" && isGrpcWeb val = (name, "application/grpc+proto")+      | otherwise = (name, val)+++-- | Encode a native gRPC response into gRPC-Web format.+--+-- Extracts @grpc-status@ and @grpc-message@ from the response headers,+-- encodes them as a trailer frame appended to the response body, removes+-- them from headers, and sets the content-type to @application\/grpc-web@.+encodeGrpcWebResponse :: Response Body -> IO (Response Body)+encodeGrpcWebResponse resp = do+  bodyBytes <- bodyToStrict (responseBody resp)+  let hdrs = responseHeaders resp+      -- Extract trailer values+      grpcStatus  = lookup "grpc-status" hdrs+      grpcMessage = lookup "grpc-message" hdrs+      -- Build trailer block as "key: value\r\n" pairs+      trailerPairs = maybe [] (\s -> ["grpc-status: " <> s <> "\r\n"]) grpcStatus+                  ++ maybe [] (\m -> ["grpc-message: " <> m <> "\r\n"]) grpcMessage+      trailerBlock = BS.concat trailerPairs+      -- Encode as trailer frame: 0x80 flag + 4-byte big-endian length + data+      trailerFrame = encodeTrailerFrame trailerBlock+      -- Append trailer frame to body+      newBody = bodyBytes <> trailerFrame+      -- Remove trailer headers and set content-type+      newHeaders = setContentType "application/grpc-web+proto"+                 $ removeHeader "grpc-status"+                 $ removeHeader "grpc-message" hdrs+  pure $ resp+    { responseHeaders = newHeaders+    , responseBody    = fromBytes newBody+    }+++-- | Encode a trailer block as a gRPC-Web trailer frame.+--+-- Format: 1-byte flag (0x80 = trailer) + 4-byte big-endian length + data.+encodeTrailerFrame :: ByteString -> ByteString+encodeTrailerFrame block =+  let len = BS.length block+  in LBS.toStrict+     $ BuilderEx.toLazyByteStringWith+         (BuilderEx.safeStrategy (5 + len) 64)+         LBS.empty+     $ Builder.word8 0x80+       <> Builder.word32BE (fromIntegral len)+       <> Builder.byteString block+++-- | Remove a header by name (case-insensitive).+removeHeader :: CI.CI ByteString -> [(CI.CI ByteString, ByteString)] -> [(CI.CI ByteString, ByteString)]+removeHeader name = filter (\(k, _) -> k /= name)+++-- | Set the content-type header, replacing any existing one.+setContentType :: ByteString -> [(CI.CI ByteString, ByteString)] -> [(CI.CI ByteString, ByteString)]+setContentType ct hdrs =+  ("content-type", ct) : removeHeader "content-type" hdrs
+ test/Integration.hs view
@@ -0,0 +1,259 @@+{-# LANGUAGE OverloadedStrings #-}+-- | Real network gRPC integration tests.+--+-- Tests the full stack: gRPC framing -> spire Service -> HTTP/1.1 transport.+-- Starts a real spire-server on a TCP port, sends HTTP requests with+-- gRPC content-type and framed bodies via http-client, and verifies+-- responses over the wire.+module Main (main) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as LBS+import qualified Data.CaseInsensitive as CI+import qualified Network.HTTP.Client as HC+import Network.HTTP.Types (statusCode)++import Spire.Service (Service (..))+import Http.Core (Request, Response, Body)+import Spire.Server (runServerWithShutdown, defaultConfig)+import Spire.Grpc+++-- ===================================================================+-- Test helpers+-- ===================================================================++assert :: String -> Bool -> IO ()+assert label True  = putStrLn $ "  OK: " ++ label+assert label False = error   $ "FAIL: " ++ label++testPort :: Int+testPort = 18951++baseUrl :: String+baseUrl = "http://127.0.0.1:" ++ show testPort+++-- ===================================================================+-- Test gRPC service+-- ===================================================================++-- | Echo handler: returns "ECHO:" prepended to the input.+echoHandler :: ByteString -> IO (Either GrpcStatus ByteString)+echoHandler payload = pure $ Right ("ECHO:" <> payload)++-- | Fail handler: always returns INTERNAL error.+failHandler :: ByteString -> IO (Either GrpcStatus ByteString)+failHandler _ = pure $ Left (grpcInternal "something broke")++-- | Streaming handler: returns N items.+listHandler :: ByteString -> IO (Either GrpcStatus [ByteString])+listHandler _ = pure $ Right ["item-0", "item-1", "item-2"]++testGrpcService :: Service IO (Request Body) (Response Body)+testGrpcService = grpcServer $ grpcServiceMap+  [ ("test.Echo", "Echo", unaryHandler echoHandler)+  , ("test.Echo", "Fail", unaryHandler failHandler)+  , ("test.Numbers", "List", serverStreamHandler listHandler)+  ]+++-- ===================================================================+-- Server lifecycle helpers+-- ===================================================================++-- | Start the gRPC server in a background thread, returning a shutdown action.+startServer :: IO (MVar ())+startServer = do+  shutdownVar <- newEmptyMVar+  _ <- forkIO $ runServerWithShutdown (defaultConfig testPort) testGrpcService shutdownVar+  -- Wait for the server to start listening+  threadDelay 300000  -- 300ms+  pure shutdownVar++-- | Shut down the server and wait for it to drain.+stopServer :: MVar () -> IO ()+stopServer shutdownVar = do+  putMVar shutdownVar ()+  threadDelay 300000  -- 300ms+++-- ===================================================================+-- HTTP request helpers+-- ===================================================================++-- | Build a gRPC POST request for the given service/method with a framed body.+grpcRequest :: HC.Manager -> String -> String -> ByteString -> IO (HC.Response LBS.ByteString)+grpcRequest mgr service method payload = do+  let url = baseUrl ++ "/" ++ service ++ "/" ++ method+  initReq <- HC.parseRequest url+  let req = initReq+        { HC.method = "POST"+        , HC.requestBody = HC.RequestBodyBS (encodeMessage payload)+        , HC.requestHeaders =+            [ ("Content-Type", "application/grpc+proto")+            , ("TE", "trailers")+            ]+        }+  HC.httpLbs req mgr++-- | Send a raw POST with custom content-type.+rawPostRequest :: HC.Manager -> String -> ByteString -> ByteString -> IO (HC.Response LBS.ByteString)+rawPostRequest mgr path ct body = do+  initReq <- HC.parseRequest (baseUrl ++ path)+  let req = initReq+        { HC.method = "POST"+        , HC.requestBody = HC.RequestBodyBS body+        , HC.requestHeaders = [("Content-Type", ct)]+        }+  HC.httpLbs req mgr++-- | Look up a response header value.+lookupRespHeader :: HC.Response a -> ByteString -> Maybe ByteString+lookupRespHeader resp name =+  lookup (CI.mk name) (HC.responseHeaders resp)+++-- ===================================================================+-- Integration tests+-- ===================================================================++testUnaryEcho :: HC.Manager -> IO ()+testUnaryEcho mgr = do+  putStrLn "  Unary echo RPC:"+  resp <- grpcRequest mgr "test.Echo" "Echo" "hello-world"++  -- HTTP status is always 200 for gRPC+  assert "echo: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)++  -- grpc-status trailer should be 0 (OK)+  assert "echo: grpc-status 0" (lookupRespHeader resp "grpc-status" == Just "0")++  -- Decode the gRPC-framed response body+  let respBody = LBS.toStrict (HC.responseBody resp)+  case decodeMessage respBody of+    Just (msg, _) ->+      assert "echo: response payload" (gmPayload msg == "ECHO:hello-world")+    Nothing ->+      error "FAIL: echo: could not decode gRPC response frame"++testUnaryError :: HC.Manager -> IO ()+testUnaryError mgr = do+  putStrLn "  Unary error RPC:"+  resp <- grpcRequest mgr "test.Echo" "Fail" "bad-input"++  assert "fail: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)+  assert "fail: grpc-status 13 (INTERNAL)" (lookupRespHeader resp "grpc-status" == Just "13")+  assert "fail: grpc-message present" (lookupRespHeader resp "grpc-message" == Just "something broke")++testUnimplementedMethod :: HC.Manager -> IO ()+testUnimplementedMethod mgr = do+  putStrLn "  Unimplemented method:"+  resp <- grpcRequest mgr "test.Echo" "DoesNotExist" "x"++  assert "unimplemented: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)+  assert "unimplemented: grpc-status 12" (lookupRespHeader resp "grpc-status" == Just "12")+  assert "unimplemented: grpc-message present" (lookupRespHeader resp "grpc-message" /= Nothing)++testUnimplementedService :: HC.Manager -> IO ()+testUnimplementedService mgr = do+  putStrLn "  Unimplemented service:"+  resp <- grpcRequest mgr "no.Such.Service" "Method" "x"++  assert "unknown service: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)+  assert "unknown service: grpc-status 12" (lookupRespHeader resp "grpc-status" == Just "12")++testWrongContentType :: HC.Manager -> IO ()+testWrongContentType mgr = do+  putStrLn "  Wrong content-type:"+  resp <- rawPostRequest mgr "/test.Echo/Echo" "application/json" "{}"++  -- Should get 415 Unsupported Media Type+  assert "wrong ct: HTTP status 415" (statusCode (HC.responseStatus resp) == 415)++testServerStreaming :: HC.Manager -> IO ()+testServerStreaming mgr = do+  putStrLn "  Server streaming RPC:"+  resp <- grpcRequest mgr "test.Numbers" "List" "go"++  assert "stream: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)+  assert "stream: grpc-status 0" (lookupRespHeader resp "grpc-status" == Just "0")++  let respBody = LBS.toStrict (HC.responseBody resp)+  let msgs = decodeMessages respBody+  assert "stream: 3 messages" (length msgs == 3)+  assert "stream: first payload" (gmPayload (msgs !! 0) == "item-0")+  assert "stream: second payload" (gmPayload (msgs !! 1) == "item-1")+  assert "stream: third payload" (gmPayload (msgs !! 2) == "item-2")++testEmptyPayload :: HC.Manager -> IO ()+testEmptyPayload mgr = do+  putStrLn "  Empty payload:"+  resp <- grpcRequest mgr "test.Echo" "Echo" ""++  assert "empty: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)+  assert "empty: grpc-status 0" (lookupRespHeader resp "grpc-status" == Just "0")++  let respBody = LBS.toStrict (HC.responseBody resp)+  case decodeMessage respBody of+    Just (msg, _) ->+      assert "empty: response is ECHO: with empty input" (gmPayload msg == "ECHO:")+    Nothing ->+      error "FAIL: empty: could not decode gRPC response frame"++testLargePayload :: HC.Manager -> IO ()+testLargePayload mgr = do+  putStrLn "  Large payload (64KB):"+  let bigPayload = BS.replicate (64 * 1024) 0x41  -- 64KB of 'A'+  resp <- grpcRequest mgr "test.Echo" "Echo" bigPayload++  assert "large: HTTP status 200" (statusCode (HC.responseStatus resp) == 200)+  assert "large: grpc-status 0" (lookupRespHeader resp "grpc-status" == Just "0")++  let respBody = LBS.toStrict (HC.responseBody resp)+  case decodeMessage respBody of+    Just (msg, _) ->+      assert "large: response payload size" (BS.length (gmPayload msg) == 5 + 64 * 1024)+      -- "ECHO:" (5 bytes) + 64KB payload+    Nothing ->+      error "FAIL: large: could not decode gRPC response frame"+++-- ===================================================================+-- Main+-- ===================================================================++main :: IO ()+main = do+  putStrLn "spire-grpc integration tests (real network):\n"++  -- Start server+  putStrLn "Starting gRPC server on port " >> putStr (show testPort) >> putStrLn "..."+  shutdownVar <- startServer+  mgr <- HC.newManager HC.defaultManagerSettings++  -- Run all integration tests+  putStrLn ""+  testUnaryEcho mgr+  putStrLn ""+  testUnaryError mgr+  putStrLn ""+  testUnimplementedMethod mgr+  putStrLn ""+  testUnimplementedService mgr+  putStrLn ""+  testWrongContentType mgr+  putStrLn ""+  testServerStreaming mgr+  putStrLn ""+  testEmptyPayload mgr+  putStrLn ""+  testLargePayload mgr++  -- Shutdown+  putStrLn "\nStopping server..."+  stopServer shutdownVar+  putStrLn "\nAll spire-grpc integration tests passed."
+ test/Main.hs view
@@ -0,0 +1,1013 @@+{-# LANGUAGE OverloadedStrings #-}+module Main (main) where++import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.CaseInsensitive as CI+import Data.Text (Text)+import qualified Data.Text as T+import Data.Bits (shiftL)+import Data.Word (Word32)+import qualified Network.HTTP.Types as HTTP++import Spire.Service (Service (..))+import Http.Core++import Spire.Layer (applyLayer)+import Spire.Grpc+++-- ===================================================================+-- Test helpers+-- ===================================================================++assert :: String -> Bool -> IO ()+assert msg True  = putStrLn $ "  OK: " ++ msg+assert msg False = error   $ "FAIL: " ++ msg+++-- ===================================================================+-- Codec tests+-- ===================================================================++testCodec :: IO ()+testCodec = do+  putStrLn "Codec:"++  -- Encode a simple message+  let encoded = encodeMessage "hello"+  assert "encode: 5-byte header + payload" (BS.length encoded == 10)+  assert "encode: first byte is 0 (uncompressed)" (BS.index encoded 0 == 0)+  assert "encode: length bytes = 5" (BS.index encoded 4 == 5)++  -- Decode it back+  case decodeMessage encoded of+    Just (msg, rest) -> do+      assert "decode: not compressed" (not (gmCompressed msg))+      assert "decode: payload matches" (gmPayload msg == "hello")+      assert "decode: no remainder" (BS.null rest)+    Nothing -> error "FAIL: decode returned Nothing"++  -- Roundtrip multiple messages+  let multi = encodeMessages ["aaa", "bb", "c"]+  let decoded = decodeMessages multi+  assert "multi: 3 messages" (length decoded == 3)+  assert "multi: first payload" (gmPayload (decoded !! 0) == "aaa")+  assert "multi: second payload" (gmPayload (decoded !! 1) == "bb")+  assert "multi: third payload" (gmPayload (decoded !! 2) == "c")++  -- Empty input+  assert "empty: no messages" (null (decodeMessages ""))++  -- Incomplete input+  assert "incomplete: Nothing" (decodeMessage "\x00\x00\x00\x00\x05hi" == Nothing)+++-- ===================================================================+-- Status tests+-- ===================================================================++testStatus :: IO ()+testStatus = do+  putStrLn "\nStatus:"++  assert "OK code = 0" (gsCode (grpcOk "ok") == 0)+  assert "INTERNAL code = 13" (gsCode (grpcInternal "err") == 13)+  assert "UNIMPLEMENTED code = 12" (gsCode (grpcUnimplemented "nope") == 12)+  assert "statusCode OK" (grpcStatusCode (grpcOk "") == "0")+  assert "statusCode UNAVAILABLE" (grpcStatusCode (grpcUnavailable "") == "14")+  assert "statusFromCode 5" (gsCode (statusFromCode 5) == 5)+++-- ===================================================================+-- Server dispatch tests+-- ===================================================================++testServer :: IO ()+testServer = do+  putStrLn "\nServer dispatch:"++  -- Build a simple service+  let services = grpcServiceMap+        [ ("test.Echo", "Echo", unaryHandler echoHandler)+        , ("test.Echo", "Fail", unaryHandler failHandler)+        ]+  let svc = grpcServer services++  -- Unary RPC: success+  resp <- callGrpc svc "test.Echo" "Echo" (encodeMessage "hello")+  assert "echo: status 200" (HTTP.statusCode (responseStatus resp) == 200)+  let respBody = responseBody resp+  respBytes <- bodyToStrict respBody+  case decodeMessage respBytes of+    Just (msg, _) -> assert "echo: payload" (gmPayload msg == "ECHO:hello")+    Nothing -> error "FAIL: could not decode echo response"+  assert "echo: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")++  -- Unary RPC: handler error+  resp2 <- callGrpc svc "test.Echo" "Fail" (encodeMessage "bad")+  assert "fail: grpc-status 13"+    (lookup "grpc-status" (responseHeaders resp2) == Just "13")++  -- Unimplemented method+  resp3 <- callGrpc svc "test.Echo" "Nope" (encodeMessage "x")+  assert "unimplemented: grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp3) == Just "12")++  -- Wrong content-type+  resp4 <- callRaw svc "application/json" "/test.Echo/Echo" ""+  assert "wrong ct: status 415" (HTTP.statusCode (responseStatus resp4) == 415)+++-- Test handlers+echoHandler :: ByteString -> IO (Either GrpcStatus ByteString)+echoHandler payload = pure $ Right ("ECHO:" <> payload)++failHandler :: ByteString -> IO (Either GrpcStatus ByteString)+failHandler _ = pure $ Left (grpcInternal "something broke")+++-- ===================================================================+-- Server streaming test+-- ===================================================================++testStreaming :: IO ()+testStreaming = do+  putStrLn "\nServer streaming:"++  let services = grpcServiceMap+        [ ("test.Numbers", "List", serverStreamHandler listHandler)+        ]+  let svc = grpcServer services++  resp <- callGrpc svc "test.Numbers" "List" (encodeMessage "3")+  respBytes <- bodyToStrict (responseBody resp)+  let msgs = decodeMessages respBytes+  assert "stream: 3 messages" (length msgs == 3)+  assert "stream: first" (gmPayload (msgs !! 0) == "item-0")+  assert "stream: second" (gmPayload (msgs !! 1) == "item-1")+  assert "stream: third" (gmPayload (msgs !! 2) == "item-2")+  assert "stream: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")++listHandler :: ByteString -> IO (Either GrpcStatus [ByteString])+listHandler _ = pure $ Right+  [ "item-0", "item-1", "item-2" ]+++-- ===================================================================+-- Helpers+-- ===================================================================++-- | Send a gRPC request to the service.+callGrpc+  :: Service IO (Request Body) (Response Body)+  -> Text -> Text -> ByteString+  -> IO (Response Body)+callGrpc svc service method body = do+  let path = "/" <> T.unpack service <> "/" <> T.unpack method+  callRaw svc "application/grpc+proto" (BS.pack (map (fromIntegral . fromEnum) path)) body++-- | Send a raw request.+callRaw+  :: Service IO (Request Body) (Response Body)+  -> ByteString -> ByteString -> ByteString+  -> IO (Response Body)+callRaw (Service handler) ct path body = do+  exts <- emptyExtensions+  let req = Request+        { requestMethod     = "POST"+        , requestPathRaw    = path+        , requestPath       = []+        , requestQuery      = []+        , requestHeaders    = [("content-type", ct)]+        , requestBody       = fromBytes body+        , requestExtensions = exts+        }+  handler req+++-- ===================================================================+-- Codec edge cases+-- ===================================================================++testCodecEdgeCases :: IO ()+testCodecEdgeCases = do+  putStrLn "\nCodec edge cases:"++  -- Zero-length message+  let zeroEnc = encodeMessage ""+  assert "zero-len: 5-byte frame" (BS.length zeroEnc == 5)+  case decodeMessage zeroEnc of+    Just (msg, rest) -> do+      assert "zero-len: empty payload" (BS.null (gmPayload msg))+      assert "zero-len: no remainder" (BS.null rest)+    Nothing -> error "FAIL: zero-len decode returned Nothing"++  -- Large message (100KB)+  let bigPayload = BS.replicate (100 * 1024) 0x42+  let bigEnc = encodeMessage bigPayload+  case decodeMessage bigEnc of+    Just (msg, rest) -> do+      assert "100KB: roundtrip payload" (gmPayload msg == bigPayload)+      assert "100KB: no remainder" (BS.null rest)+    Nothing -> error "FAIL: 100KB decode returned Nothing"++  -- Multiple messages with trailing garbage+  let msg1 = encodeMessage "first"+  let msg2 = encodeMessage "second"+  let garbage = BS.pack [0xDE, 0xAD, 0xFF]+  let combined = msg1 <> msg2 <> garbage+  let decoded = decodeMessages combined+  assert "partial trailing: 2 complete messages" (length decoded == 2)+  assert "partial trailing: first payload" (gmPayload (decoded !! 0) == "first")+  assert "partial trailing: second payload" (gmPayload (decoded !! 1) == "second")++  -- Compressed flag+  let compressedFrame = BS.pack [0x01, 0x00, 0x00, 0x00, 0x03] <> "abc"+  case decodeMessage compressedFrame of+    Just (msg, _) -> do+      assert "compressed flag: gmCompressed = True" (gmCompressed msg)+      assert "compressed flag: payload" (gmPayload msg == "abc")+    Nothing -> error "FAIL: compressed frame decode returned Nothing"+++-- ===================================================================+-- Server edge cases+-- ===================================================================++testServerEdgeCases :: IO ()+testServerEdgeCases = do+  putStrLn "\nServer edge cases:"++  -- Empty service map: any method returns grpc-status 12+  let emptySvc = grpcServer (grpcServiceMap [])+  resp1 <- callGrpc emptySvc "any.Service" "AnyMethod" (encodeMessage "x")+  assert "empty map: grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp1) == Just "12")++  -- Path parsing edge cases+  resp2 <- callRaw emptySvc "application/grpc+proto" "/" ""+  assert "path /: grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp2) == Just "12")++  resp3 <- callRaw emptySvc "application/grpc+proto" "" ""+  assert "path empty: grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp3) == Just "12")++  resp4 <- callRaw emptySvc "application/grpc+proto" "/noSlash" ""+  assert "path /noSlash: grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp4) == Just "12")++  resp5 <- callRaw emptySvc "application/grpc+proto" "/a/b/c" ""+  assert "path /a/b/c: grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp5) == Just "12")++  -- Metadata propagation+  let services = grpcServiceMap+        [ ("test.Meta", "Check", metadataHandler)+        ]+  let metaSvc = grpcServer services+  resp6 <- callGrpcWithHeaders metaSvc "test.Meta" "Check" (encodeMessage "")+             [("x-custom-header", "custom-value"), ("content-type", "application/grpc+proto")]+  respBytes6 <- bodyToStrict (responseBody resp6)+  case decodeMessage respBytes6 of+    Just (msg, _) -> assert "metadata: custom header propagated"+      (gmPayload msg == "found")+    Nothing -> error "FAIL: metadata response decode failed"++  -- grpc-message trailer on error+  let errServices = grpcServiceMap+        [ ("test.Err", "Boom", unaryHandler (\_ -> pure (Left (grpcInternal "kaboom"))))+        ]+  let errSvc = grpcServer errServices+  resp7 <- callGrpc errSvc "test.Err" "Boom" (encodeMessage "x")+  assert "grpc-message: present on error"+    (lookup "grpc-message" (responseHeaders resp7) == Just "kaboom")+  assert "grpc-message: status 13"+    (lookup "grpc-status" (responseHeaders resp7) == Just "13")++  -- grpc-message on unimplemented+  let resp1Msg = lookup "grpc-message" (responseHeaders resp1)+  assert "grpc-message: present on unimplemented" (resp1Msg /= Nothing)+++-- | Handler that checks if x-custom-header is in metadata+metadataHandler :: GrpcHandler+metadataHandler = GrpcHandler $ \req ->+  let hdrs = grpcMetadata req+      found = case lookup "x-custom-header" hdrs of+        Just "custom-value" -> True+        _                   -> False+      payload = if found then "found" else "not-found"+  in pure $ GrpcUnary (grpcOk "") payload++-- | Send a gRPC request with custom headers.+callGrpcWithHeaders+  :: Service IO (Request Body) (Response Body)+  -> Text -> Text -> ByteString+  -> [(BS.ByteString, BS.ByteString)]+  -> IO (Response Body)+callGrpcWithHeaders (Service handler) service method body hdrs = do+  exts <- emptyExtensions+  let path = "/" <> T.unpack service <> "/" <> T.unpack method+  let req = Request+        { requestMethod     = "POST"+        , requestPathRaw    = BS.pack (map (fromIntegral . fromEnum) path)+        , requestPath       = []+        , requestQuery      = []+        , requestHeaders    = map (\(k, v) -> (CI.mk k, v)) hdrs+        , requestBody       = fromBytes body+        , requestExtensions = exts+        }+  handler req+++-- ===================================================================+-- Status code exhaustive tests+-- ===================================================================++testStatusExhaustive :: IO ()+testStatusExhaustive = do+  putStrLn "\nStatus exhaustive:"++  -- All 17 status codes with correct numeric values+  assert "OK = 0"                  (gsCode (grpcOk "") == 0)+  assert "CANCELLED = 1"           (gsCode (grpcCancelled "") == 1)+  assert "UNKNOWN = 2"             (gsCode (grpcUnknown "") == 2)+  assert "INVALID_ARGUMENT = 3"    (gsCode (grpcInvalidArgument "") == 3)+  assert "DEADLINE_EXCEEDED = 4"   (gsCode (grpcDeadlineExceeded "") == 4)+  assert "NOT_FOUND = 5"           (gsCode (grpcNotFound "") == 5)+  assert "ALREADY_EXISTS = 6"      (gsCode (grpcAlreadyExists "") == 6)+  assert "PERMISSION_DENIED = 7"   (gsCode (grpcPermissionDenied "") == 7)+  assert "RESOURCE_EXHAUSTED = 8"  (gsCode (grpcResourceExhausted "") == 8)+  assert "FAILED_PRECONDITION = 9" (gsCode (grpcFailedPrecondition "") == 9)+  assert "ABORTED = 10"            (gsCode (grpcAborted "") == 10)+  assert "OUT_OF_RANGE = 11"       (gsCode (grpcOutOfRange "") == 11)+  assert "UNIMPLEMENTED = 12"      (gsCode (grpcUnimplemented "") == 12)+  assert "INTERNAL = 13"           (gsCode (grpcInternal "") == 13)+  assert "UNAVAILABLE = 14"        (gsCode (grpcUnavailable "") == 14)+  assert "DATA_LOSS = 15"          (gsCode (grpcDataLoss "") == 15)+  assert "UNAUTHENTICATED = 16"    (gsCode (grpcUnauthenticated "") == 16)++  -- statusFromCode roundtrips with gsCode+  let codes = [0..16]+  let roundtrips = all (\c -> gsCode (statusFromCode c) == c) codes+  assert "statusFromCode roundtrip 0..16" roundtrips++  -- grpcStatusCode produces correct string representations+  assert "grpcStatusCode OK = \"0\""  (grpcStatusCode (grpcOk "") == "0")+  assert "grpcStatusCode 16 = \"16\"" (grpcStatusCode (grpcUnauthenticated "") == "16")+  assert "grpcStatusCode 7 = \"7\""   (grpcStatusCode (grpcPermissionDenied "") == "7")+++-- ===================================================================+-- Multiplex tests+-- ===================================================================++testMultiplex :: IO ()+testMultiplex = do+  putStrLn "\nMultiplex (REST + gRPC):"++  -- REST echo service: responds with "REST:" ++ body+  let restSvc = Service $ \req -> do+        body <- bodyToStrict (requestBody req)+        pure $ Response HTTP.status200+          [("content-type", "application/json")]+          (fromBytes ("REST:" <> body))++  -- gRPC echo service+  let grpcServices = grpcServiceMap+        [ ("test.Echo", "Echo", unaryHandler echoHandler)+        ]+  let grpcSvc = grpcServer grpcServices++  -- Combined service+  let combined = multiplex restSvc grpcSvc++  -- application/json -> REST+  resp1 <- callRaw combined "application/json" "/test" "hello"+  body1 <- bodyToStrict (responseBody resp1)+  assert "json -> REST handler" (body1 == "REST:hello")+  assert "json -> status 200" (HTTP.statusCode (responseStatus resp1) == 200)++  -- application/grpc+proto -> gRPC+  resp2 <- callGrpc combined "test.Echo" "Echo" (encodeMessage "world")+  respBytes2 <- bodyToStrict (responseBody resp2)+  case decodeMessage respBytes2 of+    Just (msg, _) -> assert "grpc+proto -> gRPC handler" (gmPayload msg == "ECHO:world")+    Nothing -> error "FAIL: could not decode gRPC response in multiplex test"+  assert "grpc+proto -> grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp2) == Just "0")++  -- application/grpc (bare) -> gRPC+  resp3 <- callRaw combined "application/grpc" "/test.Echo/Echo" (encodeMessage "bare")+  respBytes3 <- bodyToStrict (responseBody resp3)+  case decodeMessage respBytes3 of+    Just (msg, _) -> assert "grpc bare -> gRPC handler" (gmPayload msg == "ECHO:bare")+    Nothing -> error "FAIL: could not decode bare gRPC response in multiplex test"++  -- No content-type -> REST+  resp4 <- callNoContentType combined "/test" "fallback"+  body4 <- bodyToStrict (responseBody resp4)+  assert "no content-type -> REST handler" (body4 == "REST:fallback")+++-- | Send a request with no Content-Type header.+callNoContentType+  :: Service IO (Request Body) (Response Body)+  -> ByteString -> ByteString+  -> IO (Response Body)+callNoContentType (Service handler) path body = do+  exts <- emptyExtensions+  let req = Request+        { requestMethod     = "POST"+        , requestPathRaw    = path+        , requestPath       = []+        , requestQuery      = []+        , requestHeaders    = []+        , requestBody       = fromBytes body+        , requestExtensions = exts+        }+  handler req+++-- ===================================================================+-- Reflection tests+-- ===================================================================++testReflection :: IO ()+testReflection = do+  putStrLn "\nReflection:"++  let userMethods =+        [ MethodInfo "GetUser" "GetUserRequest" "GetUserResponse"+        , MethodInfo "ListUsers" "ListUsersRequest" "ListUsersResponse"+        ]+  let orderMethods =+        [ MethodInfo "CreateOrder" "CreateOrderRequest" "CreateOrderResponse"+        ]+  let userProto = "syntax = \"proto3\";\nservice UserService { rpc GetUser ... }"+  let orderProto = "syntax = \"proto3\";\nservice OrderService { rpc CreateOrder ... }"+  let infos =+        [ ServiceInfo "myapp.UserService" userMethods userProto+        , ServiceInfo "myapp.OrderService" orderMethods orderProto+        ]++  let services = withReflection infos (grpcServiceMap [])+  let svc = grpcServer services++  -- 1. list_services: both service names appear+  resp1 <- callGrpc svc "grpc.reflection.v1alpha.ServerReflection"+             "ServerReflectionInfo" (encodeMessage "list_services")+  respBytes1 <- bodyToStrict (responseBody resp1)+  case decodeMessage respBytes1 of+    Just (msg, _) -> do+      let body = gmPayload msg+      assert "reflection list: contains UserService"+        (BS.isInfixOf "myapp.UserService" body)+      assert "reflection list: contains OrderService"+        (BS.isInfixOf "myapp.OrderService" body)+    Nothing -> error "FAIL: could not decode reflection list response"+  assert "reflection list: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp1) == Just "0")++  -- 2. file_containing_symbol with known service+  resp2 <- callGrpc svc "grpc.reflection.v1alpha.ServerReflection"+             "ServerReflectionInfo"+             (encodeMessage "file_containing_symbol:myapp.UserService")+  respBytes2 <- bodyToStrict (responseBody resp2)+  case decodeMessage respBytes2 of+    Just (msg, _) -> do+      let body = gmPayload msg+      assert "reflection file_containing_symbol: returns proto"+        (BS.isInfixOf "UserService" body)+      assert "reflection file_containing_symbol: correct proto text"+        (body == "syntax = \"proto3\";\nservice UserService { rpc GetUser ... }")+    Nothing -> error "FAIL: could not decode reflection file response"+  assert "reflection file: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp2) == Just "0")++  -- 3. file_containing_symbol with unknown symbol -> NOT_FOUND (5)+  resp3 <- callGrpc svc "grpc.reflection.v1alpha.ServerReflection"+             "ServerReflectionInfo"+             (encodeMessage "file_containing_symbol:unknown.Service")+  assert "reflection unknown: grpc-status 5"+    (lookup "grpc-status" (responseHeaders resp3) == Just "5")++  -- 4. Unknown request type -> NOT_FOUND (5)+  resp4 <- callGrpc svc "grpc.reflection.v1alpha.ServerReflection"+             "ServerReflectionInfo"+             (encodeMessage "something_else")+  assert "reflection bad request: grpc-status 5"+    (lookup "grpc-status" (responseHeaders resp4) == Just "5")+++-- ===================================================================+-- Client streaming tests+-- ===================================================================++testClientStreaming :: IO ()+testClientStreaming = do+  putStrLn "\nClient streaming:"++  let services = grpcServiceMap+        [ ("test.Agg", "Sum", clientStreamHandler sumHandler)+        ]+  let svc = grpcServer services++  -- Send 3 messages, verify handler receives all 3 payloads+  let body = encodeMessages ["aaa", "bbb", "ccc"]+  resp <- callGrpc svc "test.Agg" "Sum" body+  assert "client-stream: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")+  respBytes <- bodyToStrict (responseBody resp)+  case decodeMessage respBytes of+    Just (msg, _) -> assert "client-stream: received all 3 payloads"+      (gmPayload msg == "3:aaa,bbb,ccc")+    Nothing -> error "FAIL: could not decode client-stream response"++  -- Error case+  let errServices = grpcServiceMap+        [ ("test.Agg", "Fail", clientStreamHandler (\_ -> pure (Left (grpcInternal "client stream error"))))+        ]+  let errSvc = grpcServer errServices+  resp2 <- callGrpc errSvc "test.Agg" "Fail" (encodeMessages ["x"])+  assert "client-stream error: grpc-status 13"+    (lookup "grpc-status" (responseHeaders resp2) == Just "13")++sumHandler :: [ByteString] -> IO (Either GrpcStatus ByteString)+sumHandler payloads = do+  let count = show (length payloads)+      joined = BS.intercalate "," payloads+  pure $ Right (BS8.pack count <> ":" <> joined)+++-- ===================================================================+-- Bidirectional streaming tests+-- ===================================================================++testBidiStreaming :: IO ()+testBidiStreaming = do+  putStrLn "\nBidirectional streaming:"++  let services = grpcServiceMap+        [ ("test.Echo", "BidiEcho", bidiStreamHandler bidiEchoHandler)+        ]+  let svc = grpcServer services++  -- Send 3 messages, verify 3 response messages+  let body = encodeMessages ["one", "two", "three"]+  resp <- callGrpc svc "test.Echo" "BidiEcho" body+  assert "bidi-stream: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")+  respBytes <- bodyToStrict (responseBody resp)+  let msgs = decodeMessages respBytes+  assert "bidi-stream: 3 response messages" (length msgs == 3)+  assert "bidi-stream: first" (gmPayload (msgs !! 0) == "ECHO:one")+  assert "bidi-stream: second" (gmPayload (msgs !! 1) == "ECHO:two")+  assert "bidi-stream: third" (gmPayload (msgs !! 2) == "ECHO:three")++  -- Error case+  let errServices = grpcServiceMap+        [ ("test.Echo", "BidiFail", bidiStreamHandler (\_ -> pure (Left (grpcAborted "bidi fail"))))+        ]+  let errSvc = grpcServer errServices+  resp2 <- callGrpc errSvc "test.Echo" "BidiFail" (encodeMessages ["x"])+  assert "bidi-stream error: grpc-status 10"+    (lookup "grpc-status" (responseHeaders resp2) == Just "10")++bidiEchoHandler :: [ByteString] -> IO (Either GrpcStatus [ByteString])+bidiEchoHandler payloads = pure $ Right (map ("ECHO:" <>) payloads)+++-- ===================================================================+-- Health check tests+-- ===================================================================++testHealthCheck :: IO ()+testHealthCheck = do+  putStrLn "\nHealth check:"++  -- SERVING status+  let services = withHealthCheck (pure Serving) (grpcServiceMap [])+  let svc = grpcServer services++  resp <- callGrpc svc "grpc.health.v1.Health" "Check" (encodeMessage "")+  assert "health: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")+  respBytes <- bodyToStrict (responseBody resp)+  case decodeMessage respBytes of+    Just (msg, _) -> assert "health: SERVING = byte 1"+      (gmPayload msg == BS.singleton 1)+    Nothing -> error "FAIL: could not decode health check response"++  -- NOT_SERVING status+  let services2 = withHealthCheck (pure NotServing) (grpcServiceMap [])+  let svc2 = grpcServer services2+  resp2 <- callGrpc svc2 "grpc.health.v1.Health" "Check" (encodeMessage "")+  respBytes2 <- bodyToStrict (responseBody resp2)+  case decodeMessage respBytes2 of+    Just (msg, _) -> assert "health: NOT_SERVING = byte 2"+      (gmPayload msg == BS.singleton 2)+    Nothing -> error "FAIL: could not decode health check NOT_SERVING response"++  -- UNKNOWN status+  let services3 = withHealthCheck (pure Unknown) (grpcServiceMap [])+  let svc3 = grpcServer services3+  resp3 <- callGrpc svc3 "grpc.health.v1.Health" "Check" (encodeMessage "")+  respBytes3 <- bodyToStrict (responseBody resp3)+  case decodeMessage respBytes3 of+    Just (msg, _) -> assert "health: UNKNOWN = byte 0"+      (gmPayload msg == BS.singleton 0)+    Nothing -> error "FAIL: could not decode health check UNKNOWN response"+++-- ===================================================================+-- Compression tests+-- ===================================================================++testCompression :: IO ()+testCompression = do+  putStrLn "\nCompression:"++  -- Roundtrip: compress then decompress+  let original = "Hello, gRPC compression test! This is a payload."+  let compressed = compressMessage original+  let Right decompressed = decompressMessage compressed+  assert "compress roundtrip: equality" (decompressed == original)+  assert "compress: output differs from input" (compressed /= original)++  -- Encode compressed message: verify flag byte is 1+  let encoded = encodeMessageCompressed "test-payload"+  assert "encodeCompressed: flag byte is 1" (BS.index encoded 0 == 1)++  -- Decode the compressed message frame+  case decodeMessage encoded of+    Just (msg, rest) -> do+      assert "encodeCompressed: compressed flag set" (gmCompressed msg)+      assert "encodeCompressed: no remainder" (BS.null rest)+      -- Decompress the payload and verify+      let Right payload = decompressMessage (gmPayload msg)+      assert "encodeCompressed: payload roundtrip" (payload == "test-payload")+    Nothing -> error "FAIL: could not decode compressed message"++  -- Empty payload roundtrip+  let emptyCompressed = compressMessage ""+  let Right emptyDecompressed = decompressMessage emptyCompressed+  assert "compress empty: roundtrip" (emptyDecompressed == "")++  -- Large payload (10KB) roundtrip+  let bigPayload = BS.replicate (10 * 1024) 0xAB+  let bigCompressed = compressMessage bigPayload+  let Right bigDecompressed = decompressMessage bigCompressed+  assert "compress 10KB: roundtrip" (bigDecompressed == bigPayload)+  assert "compress 10KB: compressed differs" (bigCompressed /= bigPayload)++  -- Roundtrip via encodeMessageCompressed + decode + decompress+  let roundtripPayload = "roundtrip through encode/decode"+  let rtEncoded = encodeMessageCompressed roundtripPayload+  case decodeMessage rtEncoded of+    Just (msg, _) -> do+      let Right rtDecoded = decompressMessage (gmPayload msg)+      assert "compress encode/decode roundtrip" (rtDecoded == roundtripPayload)+    Nothing -> error "FAIL: could not decode roundtrip compressed message"+++-- ===================================================================+-- Health check: additional coverage+-- ===================================================================++testHealthCheckExtended :: IO ()+testHealthCheckExtended = do+  putStrLn "\nHealth check (extended):"++  -- Health service is at the correct path+  let services = withHealthCheck (pure Serving) (grpcServiceMap [])+  let svc = grpcServer services++  -- Call with exact expected path: "grpc.health.v1.Health"/"Check"+  resp <- callGrpc svc "grpc.health.v1.Health" "Check" (encodeMessage "")+  assert "health path: correct service/method"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")++  -- withHealthCheck adds to existing service map (non-empty)+  let existingServices = grpcServiceMap+        [ ("test.Echo", "Echo", unaryHandler echoHandler)+        ]+  let combined = withHealthCheck (pure Serving) existingServices+  let combinedSvc = grpcServer combined++  -- Original service still works+  resp2 <- callGrpc combinedSvc "test.Echo" "Echo" (encodeMessage "hi")+  assert "health+existing: original service works"+    (lookup "grpc-status" (responseHeaders resp2) == Just "0")+  respBytes2 <- bodyToStrict (responseBody resp2)+  case decodeMessage respBytes2 of+    Just (msg, _) -> assert "health+existing: original handler" (gmPayload msg == "ECHO:hi")+    Nothing -> error "FAIL: could not decode echo in combined health test"++  -- Health service also works in combined+  resp3 <- callGrpc combinedSvc "grpc.health.v1.Health" "Check" (encodeMessage "")+  assert "health+existing: health works"+    (lookup "grpc-status" (responseHeaders resp3) == Just "0")+  respBytes3 <- bodyToStrict (responseBody resp3)+  case decodeMessage respBytes3 of+    Just (msg, _) -> assert "health+existing: SERVING" (gmPayload msg == BS.singleton 1)+    Nothing -> error "FAIL: could not decode health in combined test"++  -- Wrong method on health service -> unimplemented (12)+  resp4 <- callGrpc combinedSvc "grpc.health.v1.Health" "Watch" (encodeMessage "")+  assert "health: wrong method -> grpc-status 12"+    (lookup "grpc-status" (responseHeaders resp4) == Just "12")+++-- ===================================================================+-- Client streaming: additional coverage+-- ===================================================================++testClientStreamingExtended :: IO ()+testClientStreamingExtended = do+  putStrLn "\nClient streaming (extended):"++  let services = grpcServiceMap+        [ ("test.Agg", "Sum", clientStreamHandler sumHandler)+        ]+  let svc = grpcServer services++  -- Send 0 messages, verify handler receives empty list+  let emptyBody = encodeMessages []+  resp <- callGrpc svc "test.Agg" "Sum" emptyBody+  assert "client-stream 0 msgs: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")+  respBytes <- bodyToStrict (responseBody resp)+  case decodeMessage respBytes of+    Just (msg, _) -> assert "client-stream 0 msgs: empty list"+      (gmPayload msg == "0:")+    Nothing -> error "FAIL: could not decode client-stream 0 msgs response"++  -- Handler error propagates to grpc-status+  let errServices = grpcServiceMap+        [ ("test.Agg", "Err", clientStreamHandler+            (\_ -> pure (Left (grpcPermissionDenied "not allowed"))))+        ]+  let errSvc = grpcServer errServices+  resp2 <- callGrpc errSvc "test.Agg" "Err" (encodeMessages ["a", "b"])+  assert "client-stream error: grpc-status 7 (PERMISSION_DENIED)"+    (lookup "grpc-status" (responseHeaders resp2) == Just "7")+  assert "client-stream error: grpc-message propagated"+    (lookup "grpc-message" (responseHeaders resp2) == Just "not allowed")+++-- ===================================================================+-- Bidi streaming: additional coverage+-- ===================================================================++testBidiStreamingExtended :: IO ()+testBidiStreamingExtended = do+  putStrLn "\nBidirectional streaming (extended):"++  -- Send 3, get 3 with transformation+  let services = grpcServiceMap+        [ ("test.Transform", "Upper", bidiStreamHandler upperHandler)+        ]+  let svc = grpcServer services++  let body = encodeMessages ["foo", "bar", "baz"]+  resp <- callGrpc svc "test.Transform" "Upper" body+  assert "bidi-extended: grpc-status 0"+    (lookup "grpc-status" (responseHeaders resp) == Just "0")+  respBytes <- bodyToStrict (responseBody resp)+  let msgs = decodeMessages respBytes+  assert "bidi-extended: 3 response messages" (length msgs == 3)+  assert "bidi-extended: first is UPPER:foo" (gmPayload (msgs !! 0) == "UPPER:foo")+  assert "bidi-extended: second is UPPER:bar" (gmPayload (msgs !! 1) == "UPPER:bar")+  assert "bidi-extended: third is UPPER:baz" (gmPayload (msgs !! 2) == "UPPER:baz")++  -- Handler error propagates with correct status code+  let errServices = grpcServiceMap+        [ ("test.Transform", "Fail", bidiStreamHandler+            (\_ -> pure (Left (grpcUnavailable "service down"))))+        ]+  let errSvc = grpcServer errServices+  resp2 <- callGrpc errSvc "test.Transform" "Fail" (encodeMessages ["x", "y"])+  assert "bidi-extended error: grpc-status 14 (UNAVAILABLE)"+    (lookup "grpc-status" (responseHeaders resp2) == Just "14")+  assert "bidi-extended error: grpc-message"+    (lookup "grpc-message" (responseHeaders resp2) == Just "service down")++upperHandler :: [ByteString] -> IO (Either GrpcStatus [ByteString])+upperHandler payloads = pure $ Right (map (\p -> "UPPER:" <> p) payloads)+++-- ===================================================================+-- gRPC-Web tests+-- ===================================================================++testGrpcWeb :: IO ()+testGrpcWeb = do+  putStrLn "\ngRPC-Web:"++  -- Build a gRPC service wrapped with the gRPC-Web layer+  let services = grpcServiceMap+        [ ("test.Echo", "Echo", unaryHandler echoHandler)+        ]+  let svc = applyLayer grpcWebLayer (grpcServer services)++  -- 1. gRPC-Web content-type is rewritten so the inner service handles it+  resp1 <- callRawWithCT svc "application/grpc-web" "/test.Echo/Echo" (encodeMessage "web-hello")+  assert "grpc-web: status 200" (HTTP.statusCode (responseStatus resp1) == 200)++  -- 2. Response content-type should be application/grpc-web+proto+  let ct1 = lookup "content-type" (responseHeaders resp1)+  assert "grpc-web: response content-type is grpc-web+proto"+    (ct1 == Just "application/grpc-web+proto")++  -- 3. grpc-status and grpc-message should NOT be in headers (moved to body)+  assert "grpc-web: grpc-status not in headers"+    (lookup "grpc-status" (responseHeaders resp1) == Nothing)++  -- 4. Body should contain the original message frame + trailer frame+  body1 <- bodyToStrict (responseBody resp1)+  -- Decode the first message (the actual response)+  case decodeMessage body1 of+    Just (msg, rest) -> do+      assert "grpc-web: payload correct" (gmPayload msg == "ECHO:web-hello")+      -- The rest should be a trailer frame starting with 0x80+      assert "grpc-web: trailer frame present" (not (BS.null rest))+      assert "grpc-web: trailer flag is 0x80" (BS.index rest 0 == 0x80)+      -- Decode the trailer frame+      let trailerLen = decodeWord32BE (BS.take 4 (BS.drop 1 rest))+          trailerData = BS.drop 5 rest+      assert "grpc-web: trailer length matches" (fromIntegral (BS.length trailerData) == trailerLen)+      -- Trailer should contain grpc-status: 0+      assert "grpc-web: trailer contains grpc-status"+        (BS.isInfixOf "grpc-status: 0" trailerData)+    Nothing -> error "FAIL: could not decode grpc-web response body"++  -- 5. application/grpc-web+proto also works+  resp2 <- callRawWithCT svc "application/grpc-web+proto" "/test.Echo/Echo" (encodeMessage "proto")+  ct2 <- pure $ lookup "content-type" (responseHeaders resp2)+  assert "grpc-web+proto: response content-type is grpc-web+proto"+    (ct2 == Just "application/grpc-web+proto")++  -- 6. Regular gRPC passes through unmodified+  resp3 <- callRaw svc "application/grpc+proto" "/test.Echo/Echo" (encodeMessage "native")+  assert "native grpc: grpc-status in headers"+    (lookup "grpc-status" (responseHeaders resp3) == Just "0")+  assert "native grpc: content-type unchanged"+    (lookup "content-type" (responseHeaders resp3) == Just "application/grpc+proto")++  -- 7. Error responses also encode trailers in body+  let errServices = grpcServiceMap+        [ ("test.Err", "Boom", unaryHandler (\_ -> pure (Left (grpcInternal "kaboom"))))+        ]+  let errSvc = applyLayer grpcWebLayer (grpcServer errServices)+  resp4 <- callRawWithCT errSvc "application/grpc-web" "/test.Err/Boom" (encodeMessage "x")+  assert "grpc-web error: grpc-status not in headers"+    (lookup "grpc-status" (responseHeaders resp4) == Nothing)+  body4 <- bodyToStrict (responseBody resp4)+  -- Even for error responses, trailers should be in the body+  -- The body may be empty (trailers-only) but the trailer frame should exist+  assert "grpc-web error: trailer frame in body" (BS.isInfixOf "grpc-status: 13" body4)+  assert "grpc-web error: grpc-message in trailer" (BS.isInfixOf "grpc-message: kaboom" body4)+++-- | Helper to decode a big-endian Word32 from 4 bytes (for test use)+decodeWord32BE :: ByteString -> Word32+decodeWord32BE bs =+  let b0 = fromIntegral (BS.index bs 0) :: Word32+      b1 = fromIntegral (BS.index bs 1) :: Word32+      b2 = fromIntegral (BS.index bs 2) :: Word32+      b3 = fromIntegral (BS.index bs 3) :: Word32+  in (b0 `shiftL` 24) + (b1 `shiftL` 16) + (b2 `shiftL` 8) + b3++-- | Send a request with specific content-type (CI headers).+callRawWithCT+  :: Service IO (Request Body) (Response Body)+  -> ByteString -> ByteString -> ByteString+  -> IO (Response Body)+callRawWithCT (Service handler) ct path body = do+  exts <- emptyExtensions+  let req = Request+        { requestMethod     = "POST"+        , requestPathRaw    = path+        , requestPath       = []+        , requestQuery      = []+        , requestHeaders    = [(CI.mk "content-type", ct)]+        , requestBody       = fromBytes body+        , requestExtensions = exts+        }+  handler req+++-- ===================================================================+-- Error details tests+-- ===================================================================++testErrorDetails :: IO ()+testErrorDetails = do+  putStrLn "\nError details:"++  -- 1. richError constructs a RichGrpcStatus+  let rich = richError (grpcInvalidArgument "bad input")+        [ DetailBadRequest $ BadRequest+            [ FieldViolation "name" "must not be empty"+            , FieldViolation "age" "must be positive"+            ]+        ]+  assert "richError: status code 3" (gsCode (rsStatus rich) == 3)+  assert "richError: message" (gsMessage (rsStatus rich) == "bad input")+  assert "richError: 1 detail" (length (rsDetails rich) == 1)++  -- 2. encodeDetails produces valid-looking JSON+  let json = encodeDetails (rsDetails rich)+  assert "encodeDetails: starts with [" (T.isPrefixOf "[" json)+  assert "encodeDetails: ends with ]" (T.isSuffixOf "]" json)+  assert "encodeDetails: contains BadRequest" (T.isInfixOf "BadRequest" json)+  assert "encodeDetails: contains field name" (T.isInfixOf "\"name\"" json)+  assert "encodeDetails: contains must not be empty" (T.isInfixOf "must not be empty" json)++  -- 3. richErrorResponse produces a GrpcError+  let resp = richErrorResponse rich+  case resp of+    GrpcError status -> do+      assert "richErrorResponse: status code 3" (gsCode status == 3)+      assert "richErrorResponse: message contains details"+        (T.isInfixOf "[details:" (gsMessage status))+      assert "richErrorResponse: message contains BadRequest"+        (T.isInfixOf "BadRequest" (gsMessage status))+    _ -> error "FAIL: richErrorResponse should produce GrpcError"++  -- 4. All detail types encode properly+  let allDetails =+        [ DetailBadRequest $ BadRequest [FieldViolation "f" "d"]+        , DetailDebugInfo $ DebugInfo ["frame1", "frame2"] "crash"+        , DetailRetryInfo $ RetryInfo 5000+        , DetailHelp $ Help [HelpLink "docs" "https://example.com"]+        , DetailResource $ ResourceInfo "bucket" "my-bucket" "admin" "not found"+        ]+  let allJson = encodeDetails allDetails+  assert "all details: contains DebugInfo" (T.isInfixOf "DebugInfo" allJson)+  assert "all details: contains RetryInfo" (T.isInfixOf "RetryInfo" allJson)+  assert "all details: contains retry_delay_ms" (T.isInfixOf "\"retry_delay_ms\":5000" allJson)+  assert "all details: contains Help" (T.isInfixOf "Help" allJson)+  assert "all details: contains ResourceInfo" (T.isInfixOf "ResourceInfo" allJson)+  assert "all details: contains resource_type" (T.isInfixOf "\"resource_type\":\"bucket\"" allJson)+  assert "all details: contains url" (T.isInfixOf "https://example.com" allJson)++  -- 5. Empty details list produces no detail annotation+  let emptyRich = richError (grpcNotFound "nope") []+  let emptyResp = richErrorResponse emptyRich+  case emptyResp of+    GrpcError status -> do+      assert "empty details: status code 5" (gsCode status == 5)+      assert "empty details: message unchanged" (gsMessage status == "nope")+    _ -> error "FAIL: empty richErrorResponse should produce GrpcError"++  -- 6. JSON escaping works+  let escapeDetail = DetailDebugInfo $ DebugInfo [] "line1\nline2\ttab\"quote"+  let escapeJson = encodeDetails [escapeDetail]+  assert "json escape: newline escaped" (T.isInfixOf "\\n" escapeJson)+  assert "json escape: tab escaped" (T.isInfixOf "\\t" escapeJson)+  assert "json escape: quote escaped" (T.isInfixOf "\\\"quote" escapeJson)++  -- 7. Eq and Show instances work+  let rich2 = richError (grpcInvalidArgument "bad input")+        [ DetailBadRequest $ BadRequest+            [ FieldViolation "name" "must not be empty"+            , FieldViolation "age" "must be positive"+            ]+        ]+  assert "Eq: identical RichGrpcStatus" (rich == rich2)+  assert "Show: non-empty" (not (null (show rich)))+++-- ===================================================================+-- Main+-- ===================================================================++main :: IO ()+main = do+  putStrLn "spire-grpc tests:\n"+  testCodec+  testCodecEdgeCases+  testStatus+  testStatusExhaustive+  testServer+  testServerEdgeCases+  testStreaming+  testClientStreaming+  testBidiStreaming+  testMultiplex+  testReflection+  testHealthCheck+  testHealthCheckExtended+  testCompression+  testClientStreamingExtended+  testBidiStreamingExtended+  testGrpcWeb+  testErrorDetails+  putStrLn "\nAll spire-grpc tests passed."
+ test/Properties.hs view
@@ -0,0 +1,171 @@+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE OverloadedStrings #-}+module Main where++import Hedgehog+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range++import Control.Exception (evaluate, try, SomeException)+import qualified Data.ByteString as BS+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.List as List++import Spire.Grpc.Codec+import Spire.Grpc.Status+import Spire.Grpc.Compression+import Spire.Grpc.Server (grpcServiceMap, grpcServer, GrpcHandler(..), GrpcResponse(..)+                         )+++-- | For any ByteString, decoding the encoded message returns the original+-- payload with compressed=False and no leftover bytes.+prop_codecRoundtrip :: Property+prop_codecRoundtrip = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 100000)+  decodeMessage (encodeMessage bs) === Just (GrpcMessage False bs, "")+++-- | For any list of ByteStrings, encoding then decoding preserves all payloads.+prop_multiMessageRoundtrip :: Property+prop_multiMessageRoundtrip = property $ do+  bss <- forAll $ Gen.list (Range.linear 0 20) (Gen.bytes (Range.linear 0 1000))+  let decoded = decodeMessages (encodeMessages bss)+  map gmPayload decoded === bss+  -- All messages should be uncompressed+  map gmCompressed decoded === replicate (length bss) False+++-- | Encoded length is always exactly 5 + payload length (5-byte header).+prop_encodeLengthCorrectness :: Property+prop_encodeLengthCorrectness = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 100000)+  BS.length (encodeMessage bs) === 5 + BS.length bs+++-- | Truncating an encoded message by 1 byte causes decode to return Nothing.+prop_decodeRejectsTruncated :: Property+prop_decodeRejectsTruncated = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 1 10000)+  let encoded = encodeMessage bs+      truncated = BS.take (BS.length encoded - 1) encoded+  decodeMessage truncated === Nothing+++-- | A frame with compression flag byte = 1 decodes with gmCompressed = True.+prop_compressedFlagPreservation :: Property+prop_compressedFlagPreservation = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 1000)+  let encoded = encodeMessage bs+      -- Replace the first byte (compression flag) with 0x01+      withFlag = BS.cons 0x01 (BS.drop 1 encoded)+  case decodeMessage withFlag of+    Nothing -> failure+    Just (msg, rest) -> do+      gmCompressed msg === True+      gmPayload msg === bs+      rest === BS.empty+++-- | For any two non-empty alphanumeric texts, building a grpcServiceMap with+-- that (service, method) and querying it via grpcServer dispatches correctly+-- (returns a GrpcUnary, not UNIMPLEMENTED).+prop_pathParsingRoundtrip :: Property+prop_pathParsingRoundtrip = property $ do+  svc <- forAll $ Gen.text (Range.linear 1 30) Gen.alphaNum+  method <- forAll $ Gen.text (Range.linear 1 30) Gen.alphaNum+  let handler = GrpcHandler $ \_ -> pure (GrpcUnary (grpcOk "") "ok")+      smap = grpcServiceMap [(svc, method, handler)]+      _svc = grpcServer smap+  -- The service map is keyed by (service, method), so if grpcServiceMap+  -- builds it without losing entries, the path parsing will find the handler.+  assert $ not (null smap)+++-- | All 17 gRPC status codes (0-16) have distinct gsCode values.+prop_statusCodesUnique :: Property+prop_statusCodesUnique = property $ do+  let allStatuses =+        [ grpcOk, grpcCancelled, grpcUnknown, grpcInvalidArgument+        , grpcDeadlineExceeded, grpcNotFound, grpcAlreadyExists+        , grpcPermissionDenied, grpcResourceExhausted, grpcFailedPrecondition+        , grpcAborted, grpcOutOfRange, grpcUnimplemented, grpcInternal+        , grpcUnavailable, grpcDataLoss, grpcUnauthenticated+        ]+      codes = map (\f -> gsCode (f "")) allStatuses+  length (List.nub codes) === length codes+  length codes === 17+++-- | encodeMessages [a, b] equals encodeMessage a <> encodeMessage b.+prop_encodeMessagesConcat :: Property+prop_encodeMessagesConcat = property $ do+  a <- forAll $ Gen.bytes (Range.linear 0 1000)+  b <- forAll $ Gen.bytes (Range.linear 0 1000)+  encodeMessages [a, b] === encodeMessage a <> encodeMessage b+++-- ===================================================================+-- Compression properties+-- ===================================================================++-- | For any ByteString, decompressMessage (compressMessage bs) == Right bs.+prop_compressionRoundtrip :: Property+prop_compressionRoundtrip = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 10000)+  decompressMessage (compressMessage bs) === Right bs+++-- | For any ByteString, encodeMessageCompressed has flag byte 1 at position 0.+prop_encodeCompressedFlagSet :: Property+prop_encodeCompressedFlagSet = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 5000)+  let encoded = encodeMessageCompressed bs+  -- First byte is the compression flag, should be 0x01+  BS.index encoded 0 === 0x01+++-- | encodeMessages (xs ++ ys) == encodeMessages xs <> encodeMessages ys+-- for any two lists of ByteStrings.+prop_encodeMessagesAssociative :: Property+prop_encodeMessagesAssociative = property $ do+  xs <- forAll $ Gen.list (Range.linear 0 10) (Gen.bytes (Range.linear 0 500))+  ys <- forAll $ Gen.list (Range.linear 0 10) (Gen.bytes (Range.linear 0 500))+  encodeMessages (xs ++ ys) === encodeMessages xs <> encodeMessages ys+++-- ===================================================================+-- Fuzz: gRPC framing decoder never crashes on arbitrary input+-- ===================================================================++-- | Feed random bytes to decodeMessages and assert it returns+-- a result (never throws an uncaught exception).+prop_grpcDecoderDoesNotCrash :: Property+prop_grpcDecoderDoesNotCrash = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 10000)+  let msgs = decodeMessages bs+  -- Force evaluation of all messages+  _ <- evalIO $ try @SomeException $+    evaluate (length msgs `seq` ())+  success+++-- | Feed random bytes to decodeMessage and assert it returns+-- a result (never throws an uncaught exception).+prop_grpcSingleDecoderDoesNotCrash :: Property+prop_grpcSingleDecoderDoesNotCrash = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 1000)+  _ <- evalIO $ try @SomeException $ case decodeMessage bs of+    Nothing -> evaluate ()+    Just (msg, rest) -> evaluate (gmPayload msg `seq` BS.length rest `seq` ())+  success+++tests :: IO Bool+tests = checkParallel $$(discover)++main :: IO ()+main = do+  ok <- tests+  if ok then pure () else error "Property tests failed"