Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions aws_lambda_powertools/utilities/kafka/consumer_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,22 @@ def value(self) -> Any:
schema_type = None
schema_value = None
output_serializer = None
value_schema_wire_format = None

logger.debug("Deserializing value field")

if self.schema_config and self.schema_config.value_schema_type:
schema_type = self.schema_config.value_schema_type
schema_value = self.schema_config.value_schema
output_serializer = self.schema_config.value_output_serializer
value_schema_wire_format = self.schema_config.value_schema_wire_format

# Always use get_deserializer if None it will default to DEFAULT
deserializer = get_deserializer(
schema_type=schema_type,
schema_value=schema_value,
field_metadata=self.value_schema_metadata,
wire_format=value_schema_wire_format,
)
deserialized_value = deserializer.deserialize(value)

Expand Down
15 changes: 13 additions & 2 deletions aws_lambda_powertools/utilities/kafka/deserializer/avro.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import io
import logging
from typing import Any
from typing import Any, Literal

from avro.io import BinaryDecoder, DatumReader
from avro.schema import parse as parse_schema
Expand All @@ -25,11 +25,17 @@ class AvroDeserializer(DeserializerBase):
a provided Avro schema definition.
"""

def __init__(self, schema_str: str, field_metadata: dict[str, Any] | None = None):
def __init__(
self,
schema_str: str,
field_metadata: dict[str, Any] | None = None,
value_schema_wire_format: Literal["CONFLUENT"] | None = None,
):
try:
self.parsed_schema = parse_schema(schema_str)
self.reader = DatumReader(self.parsed_schema)
self.field_metatada = field_metadata
self.value_schema_wire_format = value_schema_wire_format
except Exception as e:
raise KafkaConsumerAvroSchemaParserError(
f"Invalid Avro schema. Please ensure the provided avro schema is valid: {type(e).__name__}: {str(e)}",
Expand Down Expand Up @@ -75,6 +81,11 @@ def deserialize(self, data: bytes | str) -> object:

try:
value = self._decode_input(data)
if self.value_schema_wire_format == "CONFLUENT":
# removing the first 5 bytes from payload:
# 1B magic byte 0x00
# 4B big-endian schema ID
value = value[5:]
bytes_reader = io.BytesIO(value)
decoder = BinaryDecoder(bytes_reader)
return self.reader.read(decoder)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import hashlib
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal

from aws_lambda_powertools.utilities.kafka.deserializer.default import DefaultDeserializer
from aws_lambda_powertools.utilities.kafka.deserializer.json import JsonDeserializer
Expand All @@ -13,7 +13,12 @@
_deserializer_cache: dict[str, DeserializerBase] = {}


def _get_cache_key(schema_type: str | object, schema_value: Any, field_metadata: dict[str, Any]) -> str:
def _get_cache_key(
schema_type: str | object,
schema_value: Any,
field_metadata: dict[str, Any],
wire_format: Literal["CONFLUENT"] | None,
) -> str:
schema_metadata = None

if field_metadata:
Expand All @@ -30,10 +35,15 @@ def _get_cache_key(schema_type: str | object, schema_value: Any, field_metadata:
# For objects like Protobuf, use the object id
schema_hash = f"{str(id(schema_value))}_{schema_metadata}"

return f"{schema_type}_{schema_hash}"
return f"{schema_type}_{schema_hash}_{wire_format}"


def get_deserializer(schema_type: str | object, schema_value: Any, field_metadata: Any) -> DeserializerBase:
def get_deserializer(
schema_type: str | object,
schema_value: Any,
field_metadata: Any,
wire_format: Literal["CONFLUENT"] | None = None,
) -> DeserializerBase:
"""
Factory function to get the appropriate deserializer based on schema type.

Expand Down Expand Up @@ -81,7 +91,7 @@ def get_deserializer(schema_type: str | object, schema_value: Any, field_metadat
"""

# Generate a cache key based on schema type and value
cache_key = _get_cache_key(schema_type, schema_value, field_metadata)
cache_key = _get_cache_key(schema_type, schema_value, field_metadata, wire_format)

# Check if we already have this deserializer in cache
if cache_key in _deserializer_cache:
Expand All @@ -93,7 +103,11 @@ def get_deserializer(schema_type: str | object, schema_value: Any, field_metadat
# Import here to avoid dependency if not used
from aws_lambda_powertools.utilities.kafka.deserializer.avro import AvroDeserializer

deserializer = AvroDeserializer(schema_str=schema_value, field_metadata=field_metadata)
deserializer = AvroDeserializer(
schema_str=schema_value,
field_metadata=field_metadata,
value_schema_wire_format=wire_format,
)
elif schema_type == "PROTOBUF":
# Import here to avoid dependency if not used
from aws_lambda_powertools.utilities.kafka.deserializer.protobuf import ProtobufDeserializer
Expand Down
21 changes: 21 additions & 0 deletions aws_lambda_powertools/utilities/kafka/schema_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class SchemaConfig:
Schema definition for message values. Required when value_schema_type is 'AVRO' or 'PROTOBUF'.
value_output_serializer : Any, optional
Custom output serializer for message values. Supports Pydantic classes, Dataclasses and Custom Class
value_schema_wire_format : {'CONFLUENT', None}, default=None
Set this when the payload was produced by a Confluent's schema-registry-aware serializer (KafkaAvroSerializer)
but you are supplying the Avro schema offline rather than relying on the ESM Schema Registry integration.
Only applied for AVRO values.
key_schema_type : {'AVRO', 'PROTOBUF', 'JSON', None}, default=None
Schema type for message keys.
key_schema : str, optional
Expand Down Expand Up @@ -60,24 +64,41 @@ def __init__(
value_schema_type: Literal["AVRO", "PROTOBUF", "JSON"] | None = None,
value_schema: str | None = None,
value_output_serializer: Any | None = None,
value_schema_wire_format: Literal["CONFLUENT"] | None = None,
key_schema_type: Literal["AVRO", "PROTOBUF", "JSON"] | None = None,
key_schema: str | None = None,
key_output_serializer: Any | None = None,
):
# Validate schema requirements
self._validate_schema_requirements(value_schema_type, value_schema, "value")
self._validate_schema_requirements(key_schema_type, key_schema, "key")
self._validate_wire_format(value_schema_wire_format, value_schema_type)

self.value_schema_type = value_schema_type
self.value_schema = value_schema
self.value_output_serializer = value_output_serializer
self.key_schema_type = key_schema_type
self.key_schema = key_schema
self.key_output_serializer = key_output_serializer
self.value_schema_wire_format = value_schema_wire_format

def _validate_schema_requirements(self, schema_type: str | None, schema: str | None, prefix: str) -> None:
"""Validate that schema is provided when required by schema_type."""
if schema_type in ["AVRO", "PROTOBUF"] and schema is None:
raise KafkaConsumerMissingSchemaError(
f"{prefix}_schema must be provided when {prefix}_schema_type is {schema_type}",
)

def _validate_wire_format(self, wire_format: str | None, schema_type: str | None) -> None:
"""Validate the wire format for value payload."""

if wire_format is None:
return

if wire_format != "CONFLUENT":
raise ValueError("Only 'CONFLUENT' wire format is supported.")

if schema_type != "AVRO":
raise ValueError("Wire format is supported for only for 'AVRO' schema.")

return None
40 changes: 40 additions & 0 deletions docs/utilities/kafka.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ flowchart LR
* Support for key and value deserialization
* Support for custom output serializers (e.g., dataclasses, Pydantic models)
* Support for ESM with and without Schema Registry integration
* Support for offline Avro schemas with schema-registry wire-format prefixes (Confluent only)
* Proper error handling for deserialization issues

## Terminology
Expand Down Expand Up @@ -255,6 +256,45 @@ Each Kafka record contains important metadata that you can access alongside the
| `value_schema_metadata` | Metadata about the value schema like `schemaId` and `dataFormat` | Data format and schemaId propagated when integrating with Schema Registry |
| `key_schema_metadata` | Metadata about the key schema like `schemaId` and `dataFormat` | Data format and schemaId propagated when integrating with Schema Registry |

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

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.
Said prefix is 5 bytes long, consisting of 1B magic byte (0x00) and 4B big-endian schema ID.

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.

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.

???+ info "When do I need this?"
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`).

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

```python hl_lines="10"
from aws_lambda_powertools.utilities.kafka import SchemaConfig, kafka_consumer
from aws_lambda_powertools.utilities.kafka.consumer_records import ConsumerRecords
from aws_lambda_powertools.utilities.typing import LambdaContext

AVRO_SCHEMA = open("user.avsc").read()

schema_config = SchemaConfig(
value_schema_type="AVRO",
value_schema=AVRO_SCHEMA,
value_schema_wire_format="CONFLUENT"
)


@kafka_consumer(schema_config=schema_config)
def lambda_handler(event: ConsumerRecords, context: LambdaContext):
for record in event.records:
# record.value is the fully-deserialized Avro payload
# with the 5-byte wire-format **prefix** stripped.
...
```

???+ warning "Scope"
`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.

### Custom output serializers

Transform deserialized data into your preferred object types using output serializers. This can help you integrate Kafka data with your domain models and application architecture, providing type hints, validation, and structured data access.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ def avro_encoded_key(avro_key_schema):
return base64.b64encode(bytes_writer.getvalue()).decode("utf-8")


SCHEMA_ID_PREFIX = b"\x00\x00\x00\x00\x01"


def _prepend_prefix_to_base64(encoded: str, prefix: bytes = SCHEMA_ID_PREFIX) -> str:
return base64.b64encode(prefix + base64.b64decode(encoded)).decode("utf-8")


@pytest.fixture
def avro_encoded_value_with_prefix(avro_encoded_value):
return _prepend_prefix_to_base64(avro_encoded_value)


@pytest.fixture
def kafka_event_with_avro_data(avro_encoded_value, avro_encoded_key):
return {
Expand Down Expand Up @@ -312,6 +324,64 @@ def test_kafka_consumer_without_avro_key_schema():
assert "key_schema" in str(excinfo.value)


def test_kafka_consumer_avro_produces_wrong_output_without_prefix_length_setting(
kafka_event_with_avro_data,
avro_encoded_value_with_prefix,
avro_value_schema,
lambda_context,
):
# GIVEN An Avro payload that has been serialized by a Confluent-style producer,
# so it carries a 5-byte "magic byte + schema ID" prefix in front of the Avro body
event = deepcopy(kafka_event_with_avro_data)
event["records"]["my-topic-1"][0]["value"] = avro_encoded_value_with_prefix

# AND a SchemaConfig without the new offset parameter (today's behaviour)
schema_config = SchemaConfig(value_schema_type="AVRO", value_schema=avro_value_schema)

@kafka_consumer(schema_config=schema_config)
def handler(event: ConsumerRecords, context):
return event.record.value

# WHEN/THEN The deserializer cannot know it should skip the leading bytes.
# Depending on the prefix content, this either raises or silently returns corrupted data.
# Both outcomes are broken; the fix must let callers opt into skipping the prefix.
try:
result = handler(event, lambda_context)
except KafkaConsumerDeserializationError:
return

assert result != {"name": "John Doe", "age": 30}


def test_kafka_consumer_avro_with_value_wire_format(
kafka_event_with_avro_data,
avro_encoded_value_with_prefix,
avro_value_schema,
lambda_context,
):
# GIVEN An Avro payload with a 5-byte magic-byte + schema-ID prefix
event = deepcopy(kafka_event_with_avro_data)
event["records"]["my-topic-1"][0]["value"] = avro_encoded_value_with_prefix

# AND a SchemaConfig instructed to skip the first 5 bytes before Avro decoding
schema_config = SchemaConfig(
value_schema_type="AVRO",
value_schema=avro_value_schema,
value_schema_wire_format="CONFLUENT",
)

@kafka_consumer(schema_config=schema_config)
def handler(event: ConsumerRecords, context):
return event.record.value

# WHEN The handler processes the event
result = handler(event, lambda_context)

# THEN The Avro body should be decoded correctly after the prefix is stripped
assert result["name"] == "John Doe"
assert result["age"] == 30


def test_kafka_consumer_avro_with_wrong_json_schema(
kafka_event_with_avro_data,
lambda_context,
Expand Down