diff --git a/aws_lambda_powertools/utilities/kafka/consumer_records.py b/aws_lambda_powertools/utilities/kafka/consumer_records.py index 1fa6afba15c..724161828ee 100644 --- a/aws_lambda_powertools/utilities/kafka/consumer_records.py +++ b/aws_lambda_powertools/utilities/kafka/consumer_records.py @@ -41,17 +41,20 @@ def key(self) -> Any: schema_type = None schema_value = None output_serializer = None + key_schema_wire_format = None if self.schema_config and self.schema_config.key_schema_type: schema_type = self.schema_config.key_schema_type schema_value = self.schema_config.key_schema output_serializer = self.schema_config.key_output_serializer + key_schema_wire_format = self.schema_config.key_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.key_schema_metadata, + wire_format=key_schema_wire_format, ) deserialized_value = deserializer.deserialize(key) @@ -69,6 +72,7 @@ def value(self) -> Any: schema_type = None schema_value = None output_serializer = None + value_schema_wire_format = None logger.debug("Deserializing value field") @@ -76,12 +80,14 @@ def value(self) -> Any: 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) diff --git a/aws_lambda_powertools/utilities/kafka/deserializer/avro.py b/aws_lambda_powertools/utilities/kafka/deserializer/avro.py index d3b96da9d34..e0ca94568e2 100644 --- a/aws_lambda_powertools/utilities/kafka/deserializer/avro.py +++ b/aws_lambda_powertools/utilities/kafka/deserializer/avro.py @@ -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 @@ -16,6 +16,9 @@ logger = logging.getLogger(__name__) +_CONFLUENT_HEADER_SIZE = 5 +_CONFLUENT_MAGIC_BYTE = 0x00 + class AvroDeserializer(DeserializerBase): """ @@ -25,16 +28,43 @@ 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, + 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.wire_format = 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)}", ) from e + def _strip_wire_format_header(self, value: bytes) -> bytes: + if self.wire_format is None: + return value + + if self.wire_format != "CONFLUENT": + raise KafkaConsumerDeserializationError(f"Unsupported Avro wire format: {self.wire_format}") + + if len(value) < _CONFLUENT_HEADER_SIZE: + raise KafkaConsumerDeserializationError( + "Invalid Confluent wire format: payload must contain a 5-byte header", + ) + + if value[0] != _CONFLUENT_MAGIC_BYTE: + raise KafkaConsumerDeserializationError( + "Invalid Confluent wire format: expected magic byte 0x00", + ) + + schema_id = int.from_bytes(value[1:_CONFLUENT_HEADER_SIZE], byteorder="big") + logger.debug("Deserializing Confluent payload with schema ID %s", schema_id) + return value[_CONFLUENT_HEADER_SIZE:] + def deserialize(self, data: bytes | str) -> object: """ Deserialize Avro binary data to a Python dictionary. @@ -75,9 +105,12 @@ def deserialize(self, data: bytes | str) -> object: try: value = self._decode_input(data) + value = self._strip_wire_format_header(value) bytes_reader = io.BytesIO(value) decoder = BinaryDecoder(bytes_reader) return self.reader.read(decoder) + except KafkaConsumerDeserializationError: + raise except Exception as e: raise KafkaConsumerDeserializationError( f"Error trying to deserialize avro data - {type(e).__name__}: {str(e)}", diff --git a/aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py b/aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py index c1443c83b00..706e7da3d25 100644 --- a/aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py +++ b/aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py @@ -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 @@ -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: @@ -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. @@ -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: @@ -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, + 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 diff --git a/aws_lambda_powertools/utilities/kafka/schema_config.py b/aws_lambda_powertools/utilities/kafka/schema_config.py index 96eed96984f..d901f19d2e9 100644 --- a/aws_lambda_powertools/utilities/kafka/schema_config.py +++ b/aws_lambda_powertools/utilities/kafka/schema_config.py @@ -26,6 +26,14 @@ class SchemaConfig: Schema definition for message keys. Required when key_schema_type is 'AVRO' or 'PROTOBUF'. key_output_serializer : Any, optional Custom serializer for message keys. Supports Pydantic classes, Dataclasses and Custom Class + value_schema_wire_format : {'CONFLUENT', None}, default=None + Set this when a Confluent schema-registry-aware serializer produced the value payload + but you are supplying the Avro schema offline rather than using the ESM Schema Registry integration. + Only applies to AVRO values. + key_schema_wire_format : {'CONFLUENT', None}, default=None + Set this when a Confluent schema-registry-aware serializer produced the key payload + but you are supplying the Avro schema offline rather than using the ESM Schema Registry integration. + Only applies to AVRO keys. Raises ------ @@ -63,10 +71,14 @@ def __init__( key_schema_type: Literal["AVRO", "PROTOBUF", "JSON"] | None = None, key_schema: str | None = None, key_output_serializer: Any | None = None, + value_schema_wire_format: Literal["CONFLUENT"] | None = None, + key_schema_wire_format: Literal["CONFLUENT"] | 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, "value") + self._validate_wire_format(key_schema_wire_format, key_schema_type, "key") self.value_schema_type = value_schema_type self.value_schema = value_schema @@ -74,6 +86,8 @@ def __init__( 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 + self.key_schema_wire_format = key_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.""" @@ -81,3 +95,15 @@ def _validate_schema_requirements(self, schema_type: str | None, schema: str | N 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, prefix: str) -> None: + """Validate the wire format for a key or value payload.""" + + if wire_format is None: + return + + if wire_format != "CONFLUENT": + raise ValueError(f"{prefix}_schema_wire_format must be 'CONFLUENT'.") + + if schema_type != "AVRO": + raise ValueError(f"{prefix}_schema_wire_format is supported only when {prefix}_schema_type is 'AVRO'.") diff --git a/docs/utilities/kafka.md b/docs/utilities/kafka.md index 5bbab7e3062..5fafd65df26 100644 --- a/docs/utilities/kafka.md +++ b/docs/utilities/kafka.md @@ -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 @@ -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 (for example, `KafkaAvroSerializer`), each payload carries a wire-format header before the Avro body. +The header is 5 bytes long: 1-byte magic byte (`0x00`) followed by a 4-byte big-endian schema ID. + +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. + +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. + +???+ info "When do I need this?" + 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`. + +=== "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 deserialized Avro payload + # with the validated 5-byte wire-format header removed. + ... + ``` + +???+ warning "Scope" + `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. + ### 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. diff --git a/tests/functional/kafka_consumer/_avro/test_kafka_consumer_with_avro.py b/tests/functional/kafka_consumer/_avro/test_kafka_consumer_with_avro.py index f22171c37af..bdf839e31ce 100644 --- a/tests/functional/kafka_consumer/_avro/test_kafka_consumer_with_avro.py +++ b/tests/functional/kafka_consumer/_avro/test_kafka_consumer_with_avro.py @@ -8,6 +8,8 @@ from avro.schema import parse as parse_schema from aws_lambda_powertools.utilities.kafka.consumer_records import ConsumerRecords +from aws_lambda_powertools.utilities.kafka.deserializer import deserializer as deserializer_factory +from aws_lambda_powertools.utilities.kafka.deserializer.avro import AvroDeserializer from aws_lambda_powertools.utilities.kafka.exceptions import ( KafkaConsumerAvroSchemaParserError, KafkaConsumerDeserializationError, @@ -67,6 +69,23 @@ 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 avro_encoded_key_with_prefix(avro_encoded_key): + return _prepend_prefix_to_base64(avro_encoded_key) + + @pytest.fixture def kafka_event_with_avro_data(avro_encoded_value, avro_encoded_key): return { @@ -312,6 +331,178 @@ def test_kafka_consumer_without_avro_key_schema(): assert "key_schema" in str(excinfo.value) +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 validate and remove the Confluent header + 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_key_and_value_wire_format( + kafka_event_with_avro_data, + avro_encoded_key_with_prefix, + avro_encoded_value_with_prefix, + avro_key_schema, + avro_value_schema, + lambda_context, +): + # GIVEN Confluent-framed Avro key and value payloads + event = deepcopy(kafka_event_with_avro_data) + event["records"]["my-topic-1"][0]["key"] = avro_encoded_key_with_prefix + event["records"]["my-topic-1"][0]["value"] = avro_encoded_value_with_prefix + schema_config = SchemaConfig( + value_schema_type="AVRO", + value_schema=avro_value_schema, + key_schema_type="AVRO", + key_schema=avro_key_schema, + value_schema_wire_format="CONFLUENT", + key_schema_wire_format="CONFLUENT", + ) + + @kafka_consumer(schema_config=schema_config) + def handler(event: ConsumerRecords, context): + record = event.record + return record.key, record.value + + # WHEN the handler processes both payloads + key, value = handler(event, lambda_context) + + # THEN both headers are removed before Avro deserialization + assert key == {"user_id": "user-123"} + assert value == {"name": "John Doe", "age": 30} + + +def test_kafka_consumer_rejects_short_confluent_header( + kafka_event_with_avro_data, + avro_value_schema, + lambda_context, +): + event = deepcopy(kafka_event_with_avro_data) + event["records"]["my-topic-1"][0]["value"] = base64.b64encode(b"\x00\x00\x00\x00").decode("utf-8") + 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 + + with pytest.raises(KafkaConsumerDeserializationError, match="payload must contain a 5-byte header"): + handler(event, lambda_context) + + +def test_kafka_consumer_rejects_invalid_confluent_magic_byte( + kafka_event_with_avro_data, + avro_encoded_value, + avro_value_schema, + lambda_context, +): + event = deepcopy(kafka_event_with_avro_data) + invalid_prefix = b"\x01\x00\x00\x00\x01" + event["records"]["my-topic-1"][0]["value"] = _prepend_prefix_to_base64( + avro_encoded_value, + prefix=invalid_prefix, + ) + 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 + + with pytest.raises(KafkaConsumerDeserializationError, match="expected magic byte 0x00"): + handler(event, lambda_context) + + +def test_schema_config_preserves_existing_positional_arguments(avro_value_schema, avro_key_schema): + config = SchemaConfig("AVRO", avro_value_schema, None, "AVRO", avro_key_schema, None) + + assert config.value_schema_type == "AVRO" + assert config.value_schema == avro_value_schema + assert config.key_schema_type == "AVRO" + assert config.key_schema == avro_key_schema + assert config.value_schema_wire_format is None + assert config.key_schema_wire_format is None + + +@pytest.mark.parametrize("prefix", ["value", "key"]) +def test_schema_config_rejects_wire_format_for_non_avro_schema(prefix): + kwargs = { + f"{prefix}_schema_type": "JSON", + f"{prefix}_schema_wire_format": "CONFLUENT", + } + + with pytest.raises(ValueError, match=rf"{prefix}_schema_wire_format is supported only"): + SchemaConfig(**kwargs) + + +def test_schema_config_rejects_unknown_wire_format(avro_value_schema): + with pytest.raises(ValueError, match="value_schema_wire_format must be 'CONFLUENT'"): + SchemaConfig( + value_schema_type="AVRO", + value_schema=avro_value_schema, + value_schema_wire_format="GLUE", # type: ignore[arg-type] + ) + + +def test_avro_deserializer_rejects_unknown_wire_format(avro_value_schema, avro_encoded_value): + deserializer = AvroDeserializer( + avro_value_schema, + wire_format="GLUE", # type: ignore[arg-type] + ) + + with pytest.raises(KafkaConsumerDeserializationError, match="Unsupported Avro wire format: GLUE"): + deserializer.deserialize(avro_encoded_value) + + +def test_avro_deserializer_cache_includes_wire_format(monkeypatch, avro_value_schema): + monkeypatch.setattr(deserializer_factory, "_deserializer_cache", {}) + + plain = deserializer_factory.get_deserializer("AVRO", avro_value_schema, {}) + confluent = deserializer_factory.get_deserializer( + "AVRO", + avro_value_schema, + {}, + wire_format="CONFLUENT", + ) + + assert plain is not confluent + assert plain is deserializer_factory.get_deserializer("AVRO", avro_value_schema, {}) + assert confluent is deserializer_factory.get_deserializer( + "AVRO", + avro_value_schema, + {}, + wire_format="CONFLUENT", + ) + + def test_kafka_consumer_avro_with_wrong_json_schema( kafka_event_with_avro_data, lambda_context,