wireform-proto-0.2.0.0: src/Proto/Decode/Stream.hs
{- | Streaming and incremental decoders for protobuf messages.
Protobuf streams use length-delimited framing: each message is preceded
by a varint-encoded byte length. This module provides three decoding
strategies for non-strict input:
* 'decodeMessageLazy' — single message from a lazy 'ByteString'
* 'decodeMessageStream' — list of messages from a lazy 'ByteString'
* 'decodeMessageIncremental' — continuation-based decoder that requests
more input when incomplete, suitable for integration with any streaming
library (conduit, pipes, streaming, etc.)
The existing strict 'Proto.Decode.decodeMessage' is unchanged.
-}
module Proto.Decode.Stream (
-- * Single-message lazy decode
decodeMessageLazy,
-- * Stream decoding (length-delimited framing)
decodeMessageStream,
-- * Incremental push-based decoding
IDecode (..),
decodeMessageIncremental,
feedChunk,
-- * Incremental pull-based decoding (simpler API)
DecodeStep (..),
streamDecode,
feedMore,
) where
import Data.Bits (shiftL, (.&.), (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Int (Int64)
import Data.Word (Word64)
import Proto.Decode (MessageDecode, decodeMessage)
import Proto.Internal.Wire.Decode (DecodeError (..))
{- | Decode a single message from a lazy 'ByteString'.
Strictly materialises the input before decoding. Use this when the
full message is available but arrives as lazy chunks (e.g. from a file
read or network recv).
-}
decodeMessageLazy :: MessageDecode a => BL.ByteString -> Either DecodeError a
decodeMessageLazy = decodeMessage . BL.toStrict
{-# INLINE decodeMessageLazy #-}
{- | Decode a stream of length-delimited protobuf messages.
Each message in the input must be preceded by a varint length prefix
(the standard protobuf streaming framing used by gRPC and other systems).
Results are produced lazily: only as much input is consumed as needed
to yield the next decoded message. This works with infinite or
incrementally-produced lazy 'ByteString' inputs.
Decoding stops when the input is exhausted. A per-message 'DecodeError'
is returned inline; subsequent messages are still attempted.
-}
decodeMessageStream :: MessageDecode a => BL.ByteString -> [Either DecodeError a]
decodeMessageStream lbs
| BL.null lbs = []
| otherwise = case getVarintLazy lbs of
Left e -> [Left e]
Right (len, rest) ->
let msgLen = fromIntegral len :: Int64
(msgBytes, remaining) = BL.splitAt msgLen rest
in if BL.length msgBytes < msgLen
then [Left UnexpectedEnd]
else decodeMessage (BL.toStrict msgBytes) : decodeMessageStream remaining
-- ---------------------------------------------------------------------------
-- Incremental (resumable) decoder
-- ---------------------------------------------------------------------------
{- | Result of an incremental decode step.
This is the standard incremental parser type (à la @binary@, @cereal@,
@attoparsec@) that integrates with any streaming library:
@
loop dec = case dec of
'IDone' val leftover -> handleMessage val >> loop ('decodeMessageIncremental' \`feedChunk\` leftover)
'IFail' err leftover -> handleError err
'IPartial' k -> readChunk >>= \\mbs -> loop (k mbs)
@
Feed @Just chunk@ for more input, @Nothing@ to signal end-of-input
(which produces 'IFail' if the message is incomplete).
-}
data IDecode a
= {- | Successfully decoded a message. The 'ByteString' contains
any unconsumed input that follows the message.
-}
IDone !a !ByteString
| -- | Decode failed. The 'ByteString' contains unconsumed input.
IFail !DecodeError !ByteString
| {- | More input needed. Supply @Just chunk@ for more bytes, or
@Nothing@ to signal that no more input will arrive.
-}
IPartial (Maybe ByteString -> IDecode a)
instance Show a => Show (IDecode a) where
show (IDone a bs) = "IDone " <> show a <> " (" <> show (BS.length bs) <> " bytes leftover)"
show (IFail e bs) = "IFail " <> show e <> " (" <> show (BS.length bs) <> " bytes leftover)"
show (IPartial _) = "IPartial _"
{- | Begin incremental decoding of a single length-delimited message.
The message must use the standard protobuf streaming framing: a varint
length prefix followed by that many bytes of message payload.
Feed input chunks via the 'IPartial' continuation until 'IDone' or
'IFail' is returned. The 'ByteString' in 'IDone' contains any
leftover bytes after the decoded message — pass these to the next
call to 'decodeMessageIncremental' to decode further messages from
the same stream.
Typical streaming-library integration:
@
go dec = case dec of
IDone msg leftover -> yield msg >> go (decodeMessageIncremental \`feedChunk\` leftover)
IPartial k -> do
mchunk <- await
go (k mchunk)
IFail e _ -> throwError e
@
-}
decodeMessageIncremental :: MessageDecode a => IDecode a
decodeMessageIncremental = goVarint 0 0 BS.empty
-- | Feed a chunk of input to an incremental decoder.
feedChunk :: IDecode a -> ByteString -> IDecode a
feedChunk (IPartial k) bs = k (Just bs)
feedChunk done _ = done
-- ---------------------------------------------------------------------------
-- Internal: varint phase
-- ---------------------------------------------------------------------------
goVarint :: MessageDecode a => Word64 -> Int -> ByteString -> IDecode a
goVarint !acc !shift !buf
| shift > 63 = IFail InvalidVarint buf
| BS.null buf = IPartial $ \case
Nothing -> IFail UnexpectedEnd BS.empty
Just bs
| BS.null bs -> goVarint acc shift buf
| otherwise -> goVarint acc shift bs
| otherwise =
let !b = BS.index buf 0
!rest = BS.drop 1 buf
!val = acc .|. ((fromIntegral b .&. 0x7F) `shiftL` shift)
in if b < 0x80
then
let !msgLen = fromIntegral val :: Int
in if msgLen < 0
then IFail NegativeLength rest
else goBody msgLen 0 [] rest
else goVarint val (shift + 7) rest
-- ---------------------------------------------------------------------------
-- Internal: body accumulation phase
-- ---------------------------------------------------------------------------
goBody :: MessageDecode a => Int -> Int -> [ByteString] -> ByteString -> IDecode a
goBody !needed !have !acc !buf
| BS.null buf =
if have >= needed
then finishDecode needed acc buf
else IPartial $ \case
Nothing -> IFail UnexpectedEnd (reassemble acc)
Just bs
| BS.null bs -> goBody needed have acc buf
| otherwise -> goBody needed have acc bs
| otherwise =
let !available = BS.length buf
!remaining = needed - have
in if available >= remaining
then
let (!msgPart, !leftover) = BS.splitAt remaining buf
in finishDecode needed (msgPart : acc) leftover
else goBody needed (have + available) (buf : acc) BS.empty
finishDecode :: MessageDecode a => Int -> [ByteString] -> ByteString -> IDecode a
finishDecode needed acc leftover =
let !msgBytes = case acc of
[single] | BS.length single == needed -> single
_ -> BS.concat (reverse acc)
in case decodeMessage msgBytes of
Left e -> IFail e leftover
Right a -> IDone a leftover
reassemble :: [ByteString] -> ByteString
reassemble = BS.concat . reverse
-- ---------------------------------------------------------------------------
-- Internal: lazy varint reader (for decodeMessageStream)
-- ---------------------------------------------------------------------------
getVarintLazy :: BL.ByteString -> Either DecodeError (Word64, BL.ByteString)
getVarintLazy = go 0 0
where
go :: Word64 -> Int -> BL.ByteString -> Either DecodeError (Word64, BL.ByteString)
go !acc !shift !bs
| shift > 63 = Left InvalidVarint
| otherwise = case BL.uncons bs of
Nothing -> Left UnexpectedEnd
Just (b, rest) ->
let val = acc .|. ((fromIntegral b .&. 0x7F) `shiftL` shift)
in if b < 0x80
then Right (val, rest)
else go val (shift + 7) rest
-- ---------------------------------------------------------------------------
-- Pull-based incremental decoder (DecodeStep)
-- ---------------------------------------------------------------------------
{- | Result of a pull-based incremental decode step.
Simpler than 'IDecode': just feed bytes until 'Done' or 'Fail'.
@
case streamDecode input of
Done msg leftover -> use msg
Partial k -> k moreBytes
Fail err -> handleError err
@
-}
data DecodeStep a
= -- | Successfully decoded. Leftover bytes follow.
Done !a !ByteString
| -- | Need more input. Feed an empty 'ByteString' to signal EOF.
Partial (ByteString -> DecodeStep a)
| -- | Decode failed with an error message.
Fail !String
instance Show a => Show (DecodeStep a) where
show (Done a bs) = "Done " ++ show a ++ " (" ++ show (BS.length bs) ++ " leftover)"
show (Partial _) = "Partial _"
show (Fail e) = "Fail " ++ show e
-- | Begin pull-based streaming decode of a length-delimited message.
streamDecode :: MessageDecode a => ByteString -> DecodeStep a
streamDecode = dsVarint 0 0
-- | Feed more bytes into a 'Partial' continuation.
feedMore :: DecodeStep a -> ByteString -> DecodeStep a
feedMore (Partial k) bs = k bs
feedMore step _ = step
dsVarint :: MessageDecode a => Word64 -> Int -> ByteString -> DecodeStep a
dsVarint !acc !shift !buf
| shift > 63 = Fail "varint overflow"
| BS.null buf = Partial $ \more ->
if BS.null more
then Fail "unexpected end of input in varint"
else dsVarint acc shift more
| otherwise =
let !b = BS.index buf 0
!rest = BS.drop 1 buf
!val = acc .|. ((fromIntegral b .&. 0x7F) `shiftL` shift)
in if b < 0x80
then
let !msgLen = fromIntegral val :: Int
in if msgLen < 0
then Fail "negative message length"
else dsBody msgLen 0 [] rest
else dsVarint val (shift + 7) rest
dsBody :: MessageDecode a => Int -> Int -> [ByteString] -> ByteString -> DecodeStep a
dsBody !needed !have !acc !buf
| BS.null buf =
if have >= needed
then dsFinish needed acc buf
else Partial $ \more ->
if BS.null more
then Fail "unexpected end of input in body"
else dsBody needed have acc more
| otherwise =
let !available = BS.length buf
!remaining = needed - have
in if available >= remaining
then
let (!msgPart, !leftover) = BS.splitAt remaining buf
in dsFinish needed (msgPart : acc) leftover
else dsBody needed (have + available) (buf : acc) BS.empty
dsFinish :: MessageDecode a => Int -> [ByteString] -> ByteString -> DecodeStep a
dsFinish needed acc leftover =
let !msgBytes = case acc of
[single] | BS.length single == needed -> single
_ -> BS.concat (reverse acc)
in case decodeMessage msgBytes of
Left e -> Fail (show e)
Right a -> Done a leftover