spire-grpc-0.1.0.0: test/Main.hs
{-# 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."