packages feed

spire-grpc-0.1.0.0: src/Spire/Grpc/Codec.hs

{-# 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