Skip to content

Commit e10e470

Browse files
fix(kafka): validate Confluent Avro wire format
1 parent e9c5f67 commit e10e470

6 files changed

Lines changed: 201 additions & 50 deletions

File tree

aws_lambda_powertools/utilities/kafka/consumer_records.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,20 @@ def key(self) -> Any:
4141
schema_type = None
4242
schema_value = None
4343
output_serializer = None
44+
key_schema_wire_format = None
4445

4546
if self.schema_config and self.schema_config.key_schema_type:
4647
schema_type = self.schema_config.key_schema_type
4748
schema_value = self.schema_config.key_schema
4849
output_serializer = self.schema_config.key_output_serializer
50+
key_schema_wire_format = self.schema_config.key_schema_wire_format
4951

5052
# Always use get_deserializer if None it will default to DEFAULT
5153
deserializer = get_deserializer(
5254
schema_type=schema_type,
5355
schema_value=schema_value,
5456
field_metadata=self.key_schema_metadata,
57+
wire_format=key_schema_wire_format,
5558
)
5659
deserialized_value = deserializer.deserialize(key)
5760

aws_lambda_powertools/utilities/kafka/deserializer/avro.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19+
_CONFLUENT_HEADER_SIZE = 5
20+
_CONFLUENT_MAGIC_BYTE = 0x00
21+
1922

2023
class AvroDeserializer(DeserializerBase):
2124
"""
@@ -29,18 +32,39 @@ def __init__(
2932
self,
3033
schema_str: str,
3134
field_metadata: dict[str, Any] | None = None,
32-
value_schema_wire_format: Literal["CONFLUENT"] | None = None,
35+
wire_format: Literal["CONFLUENT"] | None = None,
3336
):
3437
try:
3538
self.parsed_schema = parse_schema(schema_str)
3639
self.reader = DatumReader(self.parsed_schema)
3740
self.field_metatada = field_metadata
38-
self.value_schema_wire_format = value_schema_wire_format
41+
self.wire_format = wire_format
3942
except Exception as e:
4043
raise KafkaConsumerAvroSchemaParserError(
4144
f"Invalid Avro schema. Please ensure the provided avro schema is valid: {type(e).__name__}: {str(e)}",
4245
) from e
4346

47+
def _strip_wire_format_header(self, value: bytes) -> bytes:
48+
if self.wire_format is None:
49+
return value
50+
51+
if self.wire_format != "CONFLUENT":
52+
raise KafkaConsumerDeserializationError(f"Unsupported Avro wire format: {self.wire_format}")
53+
54+
if len(value) < _CONFLUENT_HEADER_SIZE:
55+
raise KafkaConsumerDeserializationError(
56+
"Invalid Confluent wire format: payload must contain a 5-byte header",
57+
)
58+
59+
if value[0] != _CONFLUENT_MAGIC_BYTE:
60+
raise KafkaConsumerDeserializationError(
61+
"Invalid Confluent wire format: expected magic byte 0x00",
62+
)
63+
64+
schema_id = int.from_bytes(value[1:_CONFLUENT_HEADER_SIZE], byteorder="big")
65+
logger.debug("Deserializing Confluent payload with schema ID %s", schema_id)
66+
return value[_CONFLUENT_HEADER_SIZE:]
67+
4468
def deserialize(self, data: bytes | str) -> object:
4569
"""
4670
Deserialize Avro binary data to a Python dictionary.
@@ -81,14 +105,12 @@ def deserialize(self, data: bytes | str) -> object:
81105

82106
try:
83107
value = self._decode_input(data)
84-
if self.value_schema_wire_format == "CONFLUENT":
85-
# removing the first 5 bytes from payload:
86-
# 1B magic byte 0x00
87-
# 4B big-endian schema ID
88-
value = value[5:]
108+
value = self._strip_wire_format_header(value)
89109
bytes_reader = io.BytesIO(value)
90110
decoder = BinaryDecoder(bytes_reader)
91111
return self.reader.read(decoder)
112+
except KafkaConsumerDeserializationError:
113+
raise
92114
except Exception as e:
93115
raise KafkaConsumerDeserializationError(
94116
f"Error trying to deserialize avro data - {type(e).__name__}: {str(e)}",

aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def get_deserializer(
106106
deserializer = AvroDeserializer(
107107
schema_str=schema_value,
108108
field_metadata=field_metadata,
109-
value_schema_wire_format=wire_format,
109+
wire_format=wire_format,
110110
)
111111
elif schema_type == "PROTOBUF":
112112
# Import here to avoid dependency if not used

aws_lambda_powertools/utilities/kafka/schema_config.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,20 @@ class SchemaConfig:
2020
Schema definition for message values. Required when value_schema_type is 'AVRO' or 'PROTOBUF'.
2121
value_output_serializer : Any, optional
2222
Custom output serializer for message values. Supports Pydantic classes, Dataclasses and Custom Class
23-
value_schema_wire_format : {'CONFLUENT', None}, default=None
24-
Set this when the payload was produced by a Confluent's schema-registry-aware serializer (KafkaAvroSerializer)
25-
but you are supplying the Avro schema offline rather than relying on the ESM Schema Registry integration.
26-
Only applied for AVRO values.
2723
key_schema_type : {'AVRO', 'PROTOBUF', 'JSON', None}, default=None
2824
Schema type for message keys.
2925
key_schema : str, optional
3026
Schema definition for message keys. Required when key_schema_type is 'AVRO' or 'PROTOBUF'.
3127
key_output_serializer : Any, optional
3228
Custom serializer for message keys. Supports Pydantic classes, Dataclasses and Custom Class
29+
value_schema_wire_format : {'CONFLUENT', None}, default=None
30+
Set this when a Confluent schema-registry-aware serializer produced the value payload
31+
but you are supplying the Avro schema offline rather than using the ESM Schema Registry integration.
32+
Only applies to AVRO values.
33+
key_schema_wire_format : {'CONFLUENT', None}, default=None
34+
Set this when a Confluent schema-registry-aware serializer produced the key payload
35+
but you are supplying the Avro schema offline rather than using the ESM Schema Registry integration.
36+
Only applies to AVRO keys.
3337
3438
Raises
3539
------
@@ -64,15 +68,17 @@ def __init__(
6468
value_schema_type: Literal["AVRO", "PROTOBUF", "JSON"] | None = None,
6569
value_schema: str | None = None,
6670
value_output_serializer: Any | None = None,
67-
value_schema_wire_format: Literal["CONFLUENT"] | None = None,
6871
key_schema_type: Literal["AVRO", "PROTOBUF", "JSON"] | None = None,
6972
key_schema: str | None = None,
7073
key_output_serializer: Any | None = None,
74+
value_schema_wire_format: Literal["CONFLUENT"] | None = None,
75+
key_schema_wire_format: Literal["CONFLUENT"] | None = None,
7176
):
7277
# Validate schema requirements
7378
self._validate_schema_requirements(value_schema_type, value_schema, "value")
7479
self._validate_schema_requirements(key_schema_type, key_schema, "key")
75-
self._validate_wire_format(value_schema_wire_format, value_schema_type)
80+
self._validate_wire_format(value_schema_wire_format, value_schema_type, "value")
81+
self._validate_wire_format(key_schema_wire_format, key_schema_type, "key")
7682

7783
self.value_schema_type = value_schema_type
7884
self.value_schema = value_schema
@@ -81,6 +87,7 @@ def __init__(
8187
self.key_schema = key_schema
8288
self.key_output_serializer = key_output_serializer
8389
self.value_schema_wire_format = value_schema_wire_format
90+
self.key_schema_wire_format = key_schema_wire_format
8491

8592
def _validate_schema_requirements(self, schema_type: str | None, schema: str | None, prefix: str) -> None:
8693
"""Validate that schema is provided when required by schema_type."""
@@ -89,16 +96,14 @@ def _validate_schema_requirements(self, schema_type: str | None, schema: str | N
8996
f"{prefix}_schema must be provided when {prefix}_schema_type is {schema_type}",
9097
)
9198

92-
def _validate_wire_format(self, wire_format: str | None, schema_type: str | None) -> None:
93-
"""Validate the wire format for value payload."""
99+
def _validate_wire_format(self, wire_format: str | None, schema_type: str | None, prefix: str) -> None:
100+
"""Validate the wire format for a key or value payload."""
94101

95102
if wire_format is None:
96103
return
97104

98105
if wire_format != "CONFLUENT":
99-
raise ValueError("Only 'CONFLUENT' wire format is supported.")
106+
raise ValueError(f"{prefix}_schema_wire_format must be 'CONFLUENT'.")
100107

101108
if schema_type != "AVRO":
102-
raise ValueError("Wire format is supported for only for 'AVRO' schema.")
103-
104-
return None
109+
raise ValueError(f"{prefix}_schema_wire_format is supported only when {prefix}_schema_type is 'AVRO'.")

docs/utilities/kafka.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,15 +258,15 @@ Each Kafka record contains important metadata that you can access alongside the
258258

259259
### Using an offline Avro schema with a schema-registry wire-format prefix
260260

261-
When Confluent serializes messages with its schema-registry-aware Avro serializer (i.e. `KafkaAvroSerializer`), each payload carries a short wire-format prefix in front of the Avro body.
262-
Said prefix is 5 bytes long, consisting of 1B magic byte (0x00) and 4B big-endian schema ID.
261+
When Confluent serializes messages with its schema-registry-aware Avro serializer (for example, `KafkaAvroSerializer`), each payload carries a wire-format header before the Avro body.
262+
The header is 5 bytes long: 1-byte magic byte (`0x00`) followed by a 4-byte big-endian schema ID.
263263

264-
When the ESM Schema Registry integration is enabled, Lambda strips those bytes automatically and populates `value_schema_metadata.schemaId`. But when an **offline Avro schema** is used (checked into your Lambda) and do **not** use the ESM Schema Registry integration, those prefix bytes reach the function and would otherwise corrupt Avro deserialization.
264+
When the ESM Schema Registry integration is enabled, Lambda strips those bytes and populates the record's schema metadata. When you use an **offline Avro schema** without the ESM Schema Registry integration, the header reaches the function and prevents plain Avro deserialization.
265265

266-
By setting the `value_schema_id_wire_format` argument on `SchemaConfig` to `"CONFLUENT"`, Powertools with strip the leading 5 bytes of the payload before running the Avro decoder.
266+
Set `value_schema_wire_format` or `key_schema_wire_format` on `SchemaConfig` to `"CONFLUENT"`. Powertools validates the magic byte and strips the 5-byte header before running the Avro decoder.
267267

268268
???+ info "When do I need this?"
269-
Only when you are supplying the Avro schema yourself **and** the producer is Confluent. If the ESM Schema Registry integration is on, leave this parameter at its default (`None`).
269+
Use this option when you supply the Avro schema and the producer uses the Confluent wire format. If ESM Schema Registry integration has already removed the header, leave the option as `None`.
270270

271271
=== "Offline Avro schema with a Confluent prefix"
272272

@@ -280,20 +280,20 @@ By setting the `value_schema_id_wire_format` argument on `SchemaConfig` to `"CON
280280
schema_config = SchemaConfig(
281281
value_schema_type="AVRO",
282282
value_schema=AVRO_SCHEMA,
283-
value_schema_wire_format="CONFLUENT"
283+
value_schema_wire_format="CONFLUENT",
284284
)
285285

286286

287287
@kafka_consumer(schema_config=schema_config)
288288
def lambda_handler(event: ConsumerRecords, context: LambdaContext):
289289
for record in event.records:
290-
# record.value is the fully-deserialized Avro payload
291-
# with the 5-byte wire-format **prefix** stripped.
290+
# record.value is the deserialized Avro payload
291+
# with the validated 5-byte wire-format header removed.
292292
...
293293
```
294294

295295
???+ warning "Scope"
296-
`value_schema_id_wire_format` only affects the **Avro** deserializer, just for value payloads. This implementation is easily extensible to key payloads as well if there is demand.
296+
`value_schema_wire_format` and `key_schema_wire_format` apply only to **Avro** payloads. Leave them as `None` when ESM Schema Registry integration has already removed the wire-format header.
297297

298298
### Custom output serializers
299299

0 commit comments

Comments
 (0)