wireform-proto-0.2.0.0: src/Proto/Dynamic.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE UnboxedTuples #-}
{- | Dynamic messages for runtime protobuf manipulation.
A 'DynamicMessage' can represent any protobuf message without
compile-time generated code, using field descriptors to interpret
the wire format at runtime.
Use cases:
* Proxies and middleware that forward messages without knowing types
* Schema registries and tooling
* Testing and debugging
* Dynamic configuration systems
This module also provides a schema-driven fast decode path via
'compileParseTable' and 'decodeDynamicWithSchema', inspired by
hyperpb's table-driven parser. The schema-driven path
compiles a 'ProtoMessage' schema into a flat array of 'FieldParser'
entries that a small interpreter loop evaluates against incoming
wire data, sharing the same 'DynamicValue'/'DynamicMessage' types
as the schemaless decoder.
-}
module Proto.Dynamic (
-- * Dynamic value type
DynamicValue (..),
-- * Dynamic message
DynamicMessage (..),
emptyDynamic,
dynamicField,
setDynamicField,
removeDynamicField,
-- * Encoding / decoding
encodeDynamic,
decodeDynamic,
-- * Schema-driven decoding (fast path)
ParseTable (..),
FieldParser (..),
FieldThunk,
compileParseTable,
decodeDynamicWithSchema,
-- * Conversion
dynamicToJson,
) where
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as AesonKey
import Data.Aeson.KeyMap qualified as AesonKM
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Base64 qualified as Base64
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Unsafe qualified as BSU
import Data.IORef
import Data.Int (Int32, Int64)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Proxy (Proxy (..))
import Data.Scientific (fromFloatDigits)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import Data.Word (Word32, Word64, Word8)
import GHC.Float (castWord32ToFloat, castWord64ToDouble)
import Proto.Internal.Wire (Tag (..), WireType (..), fieldTag)
import Proto.Internal.Wire.Decode (
DecodeError (..),
DecodeResult (..),
Decoder,
UMaybe (UJust, UNothing),
getDouble,
getFixed32,
getFixed64,
getFloat,
getLengthDelimited,
getTagOrU,
getText,
getVarint,
runDecoder,
runDecoder',
skipField,
validateUtf8,
)
import Proto.Internal.Wire.Encode (
putByteString,
putDouble,
putFixed32,
putFixed64,
putFloat,
putLengthDelimited,
putTag,
putText,
putVarint,
)
import Proto.Schema (
FieldDescriptor (..),
FieldLabel' (..),
FieldTypeDescriptor (..),
ProtoMessage (..),
ScalarFieldType (..),
SomeFieldDescriptor (..),
)
import System.IO.Unsafe (unsafePerformIO)
import Wireform.Builder qualified as B
import Wireform.FFI (decodeVarintSWAR, relocatePageBoundary)
-- | A dynamically-typed protobuf value.
data DynamicValue
= DynVarint !Word64
| DynSVarint !Int64
| DynFixed32 !Word32
| DynFixed64 !Word64
| DynFloat !Float
| DynDouble !Double
| DynBool !Bool
| DynString !Text
| DynBytes !ByteString
| DynMessage !DynamicMessage
| DynEnum !Int
| DynRepeated ![DynamicValue]
| DynMap !(Map DynamicValue DynamicValue)
deriving stock (Show, Eq, Ord)
-- | A dynamically-typed protobuf message, storing fields by number.
data DynamicMessage = DynamicMessage
{ dynFields :: !(Map Int DynamicValue)
, dynUnknownFields :: ![(Int, WireType, ByteString)]
}
deriving stock (Show, Eq, Ord)
-- | An empty dynamic message with no fields and no unknown fields.
emptyDynamic :: DynamicMessage
emptyDynamic = DynamicMessage Map.empty []
-- | Look up a field by its proto field number in a dynamic message.
dynamicField :: Int -> DynamicMessage -> Maybe DynamicValue
dynamicField n (DynamicMessage fs _) = Map.lookup n fs
-- | Insert or replace a field value at the given field number.
setDynamicField :: Int -> DynamicValue -> DynamicMessage -> DynamicMessage
setDynamicField n v (DynamicMessage fs unk) =
DynamicMessage (Map.insert n v fs) unk
-- | Remove a field by its proto field number, if present.
removeDynamicField :: Int -> DynamicMessage -> DynamicMessage
removeDynamicField n (DynamicMessage fs unk) =
DynamicMessage (Map.delete n fs) unk
{- | Encode a dynamic message to bytes.
Uses varint wire type for integer values, length-delimited for strings/bytes/messages.
-}
encodeDynamic :: DynamicMessage -> ByteString
encodeDynamic (DynamicMessage fs _) =
BL.toStrict $ B.toLazyByteString $ Map.foldlWithKey' encodeField mempty fs
where
encodeField acc fn val = acc <> encodeDynValue fn val
encodeDynValue :: Int -> DynamicValue -> B.Builder
encodeDynValue fn = \case
DynVarint v -> putTag fn WireVarint <> putVarint v
DynSVarint v -> putTag fn WireVarint <> putVarint (fromIntegral v)
DynFixed32 v -> putTag fn Wire32Bit <> putFixed32 v
DynFixed64 v -> putTag fn Wire64Bit <> putFixed64 v
DynFloat v -> putTag fn Wire32Bit <> putFloat v
DynDouble v -> putTag fn Wire64Bit <> putDouble v
DynBool v -> putTag fn WireVarint <> putVarint (if v then 1 else 0)
DynString v -> putTag fn WireLengthDelimited <> putText v
DynBytes v -> putTag fn WireLengthDelimited <> putByteString v
DynEnum v -> putTag fn WireVarint <> putVarint (fromIntegral v)
DynMessage m ->
let payload = encodeDynamic m
in putTag fn WireLengthDelimited <> putLengthDelimited payload
DynRepeated vs -> foldMap (encodeDynValue fn) vs
DynMap _kvs -> mempty
-- | Decode a dynamic message from bytes using wire type inference.
decodeDynamic :: ByteString -> Either DecodeError DynamicMessage
decodeDynamic = runDecoder decodeDynLoop
decodeDynLoop :: Decoder DynamicMessage
decodeDynLoop = go Map.empty
where
go !acc = do
mt <- getTagOrU
case mt of
UNothing -> pure (DynamicMessage acc [])
UJust (Tag fn wt) -> do
val <- decodeWireValue wt
let acc' = case Map.lookup fn acc of
Nothing -> Map.insert fn val acc
Just (DynRepeated vs) -> Map.insert fn (DynRepeated (vs <> [val])) acc
Just existing -> Map.insert fn (DynRepeated [existing, val]) acc
go acc'
decodeWireValue :: WireType -> Decoder DynamicValue
decodeWireValue = \case
WireVarint -> DynVarint <$> getVarint
Wire64Bit -> DynFixed64 <$> getFixed64
Wire32Bit -> DynFixed32 <$> getFixed32
WireLengthDelimited -> DynBytes <$> getLengthDelimited
wt -> skipField wt >> pure (DynBytes BS.empty)
-- | Convert a dynamic message to JSON (field numbers as keys).
dynamicToJson :: DynamicMessage -> Aeson.Value
dynamicToJson (DynamicMessage fs _) =
Aeson.Object
( AesonKM.fromList
(fmap (\(k, v) -> (AesonKey.fromText (intToText k), dynValueToJson v)) (Map.toList fs))
)
dynValueToJson :: DynamicValue -> Aeson.Value
dynValueToJson = \case
DynVarint v -> Aeson.Number (fromIntegral v)
DynSVarint v -> Aeson.Number (fromIntegral v)
DynFixed32 v -> Aeson.Number (fromIntegral v)
DynFixed64 v -> Aeson.String (word64ToText v)
DynFloat v -> Aeson.Number (fromFloatDigits v)
DynDouble v -> Aeson.Number (fromFloatDigits v)
DynBool v -> Aeson.Bool v
DynString v -> Aeson.String v
DynBytes bs -> Aeson.String (TE.decodeUtf8 (Base64.encode bs))
DynEnum v -> Aeson.Number (fromIntegral v)
DynMessage m -> dynamicToJson m
DynRepeated vs -> Aeson.toJSON (fmap dynValueToJson vs)
DynMap _ -> Aeson.object []
-- ============================================================
-- Schema-driven fast decode path (Table-Driven Parser)
-- ============================================================
{- | A thunk for parsing a single field. Takes a ByteString and offset,
returns the parsed value and new offset.
-}
type FieldThunk = ByteString -> Int -> IO (DynamicValue, Int)
-- | A single field's parse configuration in the table.
data FieldParser = FieldParser
{ fpTag :: {-# UNPACK #-} !Word64
-- ^ The wire tag (field number << 3 | wire type) this entry matches.
, fpFieldNum :: {-# UNPACK #-} !Int
-- ^ Proto field number.
, fpNextOk :: {-# UNPACK #-} !Int
-- ^ Index of next FieldParser to try on successful match (field scheduling).
, fpNextErr :: {-# UNPACK #-} !Int
-- ^ Index of next FieldParser to try on mismatch.
, fpParse :: !FieldThunk
-- ^ The thunk that actually decodes this field's value.
, fpLabel :: !FieldLabel'
-- ^ Whether this field is repeated (affects accumulation).
, fpSubmsg :: !(Maybe ParseTable)
-- ^ Nested parse table for submessage fields.
}
-- | Compiled parse table for a message type.
data ParseTable = ParseTable
{ ptFields :: !(V.Vector FieldParser)
-- ^ Field parsers in scheduled order.
, ptTagLUT :: !ByteString
{- ^ 128-byte LUT: tag byte -> index in ptFields (0xFF = miss).
For tags < 128 (field numbers 1-15), this is a direct O(1) lookup.
-}
, ptTagMap :: !(IntMap Int)
-- ^ Fallback map: wire tag -> index in ptFields, for tags >= 128.
, ptMaxMiss :: {-# UNPACK #-} !Int
-- ^ Max consecutive misses before hitting the hash table.
}
-- | Compile a 'ProtoMessage' schema into a 'ParseTable'.
compileParseTable :: forall a. ProtoMessage a => Proxy a -> ParseTable
compileParseTable proxy =
let descriptors = protoFieldDescriptors proxy
fieldList' = Map.toAscList descriptors
nFields = length fieldList'
parsers =
V.fromList
[ mkFieldParser i nFields fd
| (i, (_, SomeField fd)) <- zip [0 ..] fieldList'
]
tagLUTBytes = BS.pack (fmap tagLUTEntry [0 .. 127])
tagMap =
IntMap.fromList
[ (fromIntegral (fpTag fp), i)
| (i, fp) <- zip [0 ..] (V.toList parsers)
]
in ParseTable
{ ptFields = parsers
, ptTagLUT = tagLUTBytes
, ptTagMap = tagMap
, ptMaxMiss = min 4 nFields
}
where
fieldList = Map.toAscList (protoFieldDescriptors proxy)
nFields = length fieldList
fpList :: [(Int, FieldParser)]
fpList =
[ (i, mkFieldParser i nFields fd)
| (i, (_, SomeField fd)) <- zip [0 ..] fieldList
]
tagLUTEntry :: Word8 -> Word8
tagLUTEntry tag =
case IntMap.lookup (fromIntegral tag) tagIdxMap of
Just idx | idx < 256 -> fromIntegral idx
_ -> 0xFF
tagIdxMap :: IntMap Int
tagIdxMap =
IntMap.fromList
[ (fromIntegral (fpTag (snd fp)), fst fp)
| fp <- fpList
]
-- | Build a FieldParser from a schema FieldDescriptor.
mkFieldParser :: Int -> Int -> FieldDescriptor msg a -> FieldParser
mkFieldParser idx nFields fd =
let fn = fdNumber fd
wt = fieldWireType (fdTypeDesc fd)
tag = fieldTag fn wt
nextOk = (idx + 1) `mod` nFields
nextErr = (idx + 1) `mod` nFields
in FieldParser
{ fpTag = tag
, fpFieldNum = fn
, fpNextOk = nextOk
, fpNextErr = nextErr
, fpParse = mkThunk (fdTypeDesc fd)
, fpLabel = fdLabel fd
, fpSubmsg = Nothing
}
fieldWireType :: FieldTypeDescriptor -> WireType
fieldWireType = \case
ScalarType DoubleField -> Wire64Bit
ScalarType FloatField -> Wire32Bit
ScalarType Int32Field -> WireVarint
ScalarType Int64Field -> WireVarint
ScalarType UInt32Field -> WireVarint
ScalarType UInt64Field -> WireVarint
ScalarType SInt32Field -> WireVarint
ScalarType SInt64Field -> WireVarint
ScalarType Fixed32Field -> Wire32Bit
ScalarType Fixed64Field -> Wire64Bit
ScalarType SFixed32Field -> Wire32Bit
ScalarType SFixed64Field -> Wire64Bit
ScalarType BoolField -> WireVarint
ScalarType StringField -> WireLengthDelimited
ScalarType BytesField -> WireLengthDelimited
MessageType _ -> WireLengthDelimited
EnumType _ -> WireVarint
MapType _ _ -> WireLengthDelimited
-- | Make a decode thunk for a field type.
mkThunk :: FieldTypeDescriptor -> FieldThunk
mkThunk = \case
ScalarType DoubleField -> thunkFixed64 (DynDouble . castWord64ToDouble)
ScalarType FloatField -> thunkFixed32 (DynFloat . castWord32ToFloat)
ScalarType Int32Field -> thunkVarint DynVarint
ScalarType Int64Field -> thunkVarint DynVarint
ScalarType UInt32Field -> thunkVarint DynVarint
ScalarType UInt64Field -> thunkVarint DynVarint
ScalarType SInt32Field -> thunkVarint DynVarint
ScalarType SInt64Field -> thunkVarint DynVarint
ScalarType Fixed32Field -> thunkFixed32 DynFixed32
ScalarType Fixed64Field -> thunkFixed64 DynFixed64
ScalarType SFixed32Field -> thunkFixed32 DynFixed32
ScalarType SFixed64Field -> thunkFixed64 DynFixed64
ScalarType BoolField -> thunkVarint (\v -> DynBool (v /= 0))
ScalarType StringField -> thunkLenDelim (fmap DynString . decodeTextValue)
ScalarType BytesField -> thunkLenDelim (Right . DynBytes)
MessageType _ -> thunkLenDelim (fmap DynMessage . decodeSubmsg)
EnumType _ -> thunkVarint (DynEnum . fromIntegral)
MapType _ _ -> thunkLenDelim (Right . DynBytes)
decodeTextValue :: ByteString -> Either DecodeError Text
decodeTextValue bs
| validateUtf8 bs = Right (TE.decodeUtf8Lenient bs)
| otherwise = Left InvalidUtf8
-- Thunk builders: decode a wire value at a given offset in a ByteString.
thunkVarint :: (Word64 -> DynamicValue) -> FieldThunk
thunkVarint f bs off =
case runDecoder' getVarint bs off of
DecodeOK v off' -> pure (f v, off')
DecodeFail e -> error ("dynamic varint decode: " <> show e)
thunkFixed32 :: (Word32 -> DynamicValue) -> FieldThunk
thunkFixed32 f bs off =
case runDecoder' getFixed32 bs off of
DecodeOK v off' -> pure (f v, off')
DecodeFail e -> error ("dynamic fixed32 decode: " <> show e)
thunkFixed64 :: (Word64 -> DynamicValue) -> FieldThunk
thunkFixed64 f bs off =
case runDecoder' getFixed64 bs off of
DecodeOK v off' -> pure (f v, off')
DecodeFail e -> error ("dynamic fixed64 decode: " <> show e)
thunkLenDelim :: (ByteString -> Either DecodeError DynamicValue) -> FieldThunk
thunkLenDelim f bs off =
case runDecoder' getLengthDelimited bs off of
DecodeOK bytes off' -> case f bytes of
Right v -> pure (v, off')
Left e -> error ("dynamic len-delim decode: " <> show e)
DecodeFail e -> error ("dynamic len-delim decode: " <> show e)
-- | Decode a submessage using schemaless wire-type inference.
decodeSubmsg :: ByteString -> Either DecodeError DynamicMessage
decodeSubmsg = decodeDynamic
{- | Run a parse table on raw bytes (without a compiled table — wire-type inference).
Used as a fallback when no schema fields are compiled.
-}
decodeRaw :: ByteString -> DynamicMessage
decodeRaw bs = unsafePerformIO $ do
fieldsRef <- newIORef IntMap.empty
let len = BS.length bs
go !off
| off >= len = pure ()
| otherwise = do
case runDecoder' getVarint bs off of
DecodeOK tagW off1 -> do
let !fn = fromIntegral (tagW `div` 8) :: Int
!wt = fromIntegral (tagW `mod` 8) :: Int
case wt of
0 -> case runDecoder' getVarint bs off1 of
DecodeOK v off2 -> do
modifyIORef' fieldsRef (IntMap.insert fn (DynVarint v))
go off2
DecodeFail _ -> pure ()
1 -> case runDecoder' getFixed64 bs off1 of
DecodeOK v off2 -> do
modifyIORef' fieldsRef (IntMap.insert fn (DynFixed64 v))
go off2
DecodeFail _ -> pure ()
2 -> case runDecoder' getLengthDelimited bs off1 of
DecodeOK v off2 -> do
modifyIORef' fieldsRef (IntMap.insert fn (DynBytes v))
go off2
DecodeFail _ -> pure ()
5 -> case runDecoder' getFixed32 bs off1 of
DecodeOK v off2 -> do
modifyIORef' fieldsRef (IntMap.insert fn (DynFixed32 v))
go off2
DecodeFail _ -> pure ()
_ -> pure ()
DecodeFail _ -> pure ()
go 0
fields <- readIORef fieldsRef
pure (DynamicMessage (IntMap.foldlWithKey' (\m k v -> Map.insert k v m) Map.empty fields) [])
{- | Decode a message using a compiled 'ParseTable'.
This is the schema-driven fast decode path. The core table-driven interpreter loop:
1. Decode a tag varint.
2. If tag < 128, use the TagLUT for O(1) field lookup.
3. Otherwise, try the predicted next field ('fpNextOk').
4. On mismatch, walk 'fpNextErr' up to 'ptMaxMiss' times.
5. Fall back to the tag hash map.
6. Call the matched field's thunk ('fpParse') to decode the value.
7. Store the value in the accumulator.
8. Repeat until end of input.
-}
decodeDynamicWithSchema :: ParseTable -> ByteString -> Either DecodeError DynamicMessage
decodeDynamicWithSchema pt bs0
| BS.null bs0 = Right (DynamicMessage Map.empty [])
| V.null (ptFields pt) = Right (decodeRaw bs0)
| otherwise = unsafePerformIO $ do
let !bs = relocatePageBoundary bs0
fieldsRef <- newIORef IntMap.empty
let len = BS.length bs0
decodeTag !off
| off + 8 <= BS.length bs =
case decodeVarintSWAR bs off of
Just (v, consumed) -> Just (v, off + consumed)
Nothing -> decodeTagSlow off
| otherwise = decodeTagSlow off
decodeTagSlow !off =
case runDecoder' getVarint bs0 off of
DecodeOK v o -> Just (v, o)
DecodeFail _ -> Nothing
go !off !curIdx
| off >= len = pure ()
| otherwise =
case decodeTag off of
Nothing -> pure ()
Just (tagW, off1)
| off1 > len -> pure ()
| otherwise -> do
let !tagInt = fromIntegral tagW :: Int
mIdx <- findField pt tagW tagInt curIdx
case mIdx of
Just idx -> do
let !fp = ptFields pt V.! idx
(val, off2) <- fpParse fp bs0 off1
case fpLabel fp of
LabelRepeated -> do
fields <- readIORef fieldsRef
let fn = fpFieldNum fp
val' = case IntMap.lookup fn fields of
Just (DynRepeated vs) -> DynRepeated (vs ++ [val])
Just existing -> DynRepeated [existing, val]
Nothing -> val
writeIORef fieldsRef (IntMap.insert fn val' fields)
_ ->
modifyIORef' fieldsRef (IntMap.insert (fpFieldNum fp) val)
go off2 (fpNextOk fp)
Nothing -> do
-- Unknown field: skip it
let !wt = fromIntegral (tagW `mod` 8) :: Int
case skipWireValue wt bs0 off1 of
Just off2 -> go off2 curIdx
Nothing -> pure ()
go 0 0
fields <- readIORef fieldsRef
pure (Right (DynamicMessage (IntMap.foldlWithKey' (\m k v -> Map.insert k v m) Map.empty fields) []))
-- | Find the matching field parser for a given tag.
findField :: ParseTable -> Word64 -> Int -> Int -> IO (Maybe Int)
findField pt tagW tagInt curIdx
-- TagLUT fast path: single-byte tags (field numbers 1-15)
| tagInt >= 0
, tagInt < 128 =
let !lutVal = BSU.unsafeIndex (ptTagLUT pt) tagInt
in if lutVal /= 0xFF
then pure (Just (fromIntegral lutVal))
else pure Nothing
-- Predicted next field
| curIdx < V.length (ptFields pt)
, let fp = ptFields pt V.! curIdx
, fpTag fp == tagW =
pure (Just curIdx)
-- Walk NextErr chain
| otherwise = walkErr pt tagW curIdx (ptMaxMiss pt)
walkErr :: ParseTable -> Word64 -> Int -> Int -> IO (Maybe Int)
walkErr pt tagW curIdx !tries
| tries <= 0 = pure (IntMap.lookup (fromIntegral tagW) (ptTagMap pt))
| curIdx >= V.length (ptFields pt) = pure (IntMap.lookup (fromIntegral tagW) (ptTagMap pt))
| otherwise =
let !fp = ptFields pt V.! curIdx
in if fpTag fp == tagW
then pure (Just curIdx)
else walkErr pt tagW (fpNextErr fp) (tries - 1)
-- | Skip a wire value based on wire type. Returns new offset or Nothing.
skipWireValue :: Int -> ByteString -> Int -> Maybe Int
skipWireValue wt bs off = case wt of
0 -> case runDecoder' getVarint bs off of
DecodeOK _ off' -> Just off'
DecodeFail _ -> Nothing
1 -> let off' = off + 8 in if off' <= BS.length bs then Just off' else Nothing
2 -> case runDecoder' getVarint bs off of
DecodeOK lenW off' ->
let off'' = off' + fromIntegral lenW
in if off'' <= BS.length bs then Just off'' else Nothing
DecodeFail _ -> Nothing
5 -> let off' = off + 4 in if off' <= BS.length bs then Just off' else Nothing
_ -> Nothing
intToText :: Int -> Text
intToText n
| n < 0 = "-" <> word64ToText (fromIntegral (negate n))
| otherwise = word64ToText (fromIntegral n)
word64ToText :: Word64 -> Text
word64ToText 0 = "0"
word64ToText n = go T.empty n
where
go !acc 0 = acc
go !acc v =
let (!q, !r) = v `quotRem` 10
in go (T.cons (toEnum (fromIntegral r + 48)) acc) q