diff --git a/aws_lambda_powertools/utilities/kafka/consumer_records.py b/aws_lambda_powertools/utilities/kafka/consumer_records.py index 1fa6afba15c..84a526ecfec 100644 --- a/aws_lambda_powertools/utilities/kafka/consumer_records.py +++ b/aws_lambda_powertools/utilities/kafka/consumer_records.py @@ -69,6 +69,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 +77,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..44c7ba4c644 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 @@ -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)}", @@ -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) diff --git a/aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py b/aws_lambda_powertools/utilities/kafka/deserializer/deserializer.py index c1443c83b00..373407a6244 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, + 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 diff --git a/aws_lambda_powertools/utilities/kafka/schema_config.py b/aws_lambda_powertools/utilities/kafka/schema_config.py index 96eed96984f..96e17288953 100644 --- a/aws_lambda_powertools/utilities/kafka/schema_config.py +++ b/aws_lambda_powertools/utilities/kafka/schema_config.py @@ -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 @@ -60,6 +64,7 @@ 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, @@ -67,6 +72,7 @@ def __init__( # 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 @@ -74,6 +80,7 @@ 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 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 +88,17 @@ 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) -> 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 diff --git a/docs/utilities/kafka.md b/docs/utilities/kafka.md index 5bbab7e3062..9c43fc54071 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 (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. 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..6b7b6ab41c3 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 @@ -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 { @@ -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,