Skip to content

feat(validation): support JSON Schema referencing in validation utils #4508

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 7 additions & 2 deletions aws_lambda_powertools/utilities/validation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
logger = logging.getLogger(__name__)


def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats: Optional[Dict] = None):
def validate_data_against_schema(
data: Union[Dict, str], schema: Dict, formats: Optional[Dict] = None, handlers: Optional[Dict] = None
):
"""Validate dict data against given JSON Schema

Parameters
Expand All @@ -19,6 +21,8 @@ def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats:
JSON Schema to validate against
formats: Dict
Custom formats containing a key (e.g. int64) and a value expressed as regex or callback returning bool
handlers: Dict
Custom methods to retrieve remote schemes, keyed off of URI scheme

Raises
------
Expand All @@ -29,7 +33,8 @@ def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats:
"""
try:
formats = formats or {}
fastjsonschema.validate(definition=schema, data=data, formats=formats)
handlers = handlers or {}
fastjsonschema.validate(definition=schema, data=data, formats=formats, handlers=handlers)
except (TypeError, AttributeError, fastjsonschema.JsonSchemaDefinitionException) as e:
raise InvalidSchemaFormatError(f"Schema received: {schema}, Formats: {formats}. Error: {e}")
except fastjsonschema.JsonSchemaValueException as e:
Expand Down
19 changes: 16 additions & 3 deletions aws_lambda_powertools/utilities/validation/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ def validator(
context: Any,
inbound_schema: Optional[Dict] = None,
inbound_formats: Optional[Dict] = None,
inbound_handlers: Optional[Dict] = None,
outbound_schema: Optional[Dict] = None,
outbound_formats: Optional[Dict] = None,
outbound_handlers: Optional[Dict] = None,
envelope: str = "",
jmespath_options: Optional[Dict] = None,
**kwargs: Any,
Expand All @@ -44,6 +46,10 @@ def validator(
Custom formats containing a key (e.g. int64) and a value expressed as regex or callback returning bool
outbound_formats: Dict
Custom formats containing a key (e.g. int64) and a value expressed as regex or callback returning bool
inbound_handlers: Dict
Custom methods to retrieve remote schemes, keyed off of URI scheme
outbound_handlers: Dict
Custom methods to retrieve remote schemes, keyed off of URI scheme

Example
-------
Expand Down Expand Up @@ -127,13 +133,17 @@ def handler(event, context):

if inbound_schema:
logger.debug("Validating inbound event")
validate_data_against_schema(data=event, schema=inbound_schema, formats=inbound_formats)
validate_data_against_schema(
data=event, schema=inbound_schema, formats=inbound_formats, handlers=inbound_handlers
)

response = handler(event, context, **kwargs)

if outbound_schema:
logger.debug("Validating outbound event")
validate_data_against_schema(data=response, schema=outbound_schema, formats=outbound_formats)
validate_data_against_schema(
data=response, schema=outbound_schema, formats=outbound_formats, handlers=outbound_handlers
)

return response

Expand All @@ -142,6 +152,7 @@ def validate(
event: Any,
schema: Dict,
formats: Optional[Dict] = None,
handlers: Optional[Dict] = None,
envelope: Optional[str] = None,
jmespath_options: Optional[Dict] = None,
):
Expand All @@ -161,6 +172,8 @@ def validate(
Alternative JMESPath options to be included when filtering expr
formats: Dict
Custom formats containing a key (e.g. int64) and a value expressed as regex or callback returning bool
handlers: Dict
Custom methods to retrieve remote schemes, keyed off of URI scheme

Example
-------
Expand Down Expand Up @@ -229,4 +242,4 @@ def handler(event, context):
jmespath_options=jmespath_options,
)

validate_data_against_schema(data=event, schema=schema, formats=formats)
validate_data_against_schema(data=event, schema=schema, formats=formats, handlers=handlers)
30 changes: 30 additions & 0 deletions docs/utilities/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,33 @@ You can use our built-in [JMESPath functions](./jmespath_functions.md){target="_

???+ info
We use these for [built-in envelopes](#built-in-envelopes) to easily to decode and unwrap events from sources like Kinesis, CloudWatch Logs, etc.

### Validating with external references

JSON schema [allows schemas to reference other schemas](https://json-schema.org/understanding-json-schema/structuring#dollarref) using the `$ref` keyword. The value of `$ref` is a URI reference, but you likely don't want to launch an HTTP request to resolve this URI. Instead, you can pass resolving functions through the `handlers` parameter:

```python title="custom_reference_handlers.py"
from aws_lambda_powertools.utilities.validation import validate

SCHEMA = {
"ParentSchema": {
"type": "object",
"properties": {
"child_object": {"$ref": "testschema://ChildSchema"},
...
},
...
},
"ChildSchema": ...,
}

def handle_test_schema(uri):
schema_key = uri.split("://")[1]
return SCHEMA[schema_key]

test_schema_handlers = {"testschema": handle_test_schema}

parent_event = {"child_object": {...}}

validate(event=parent_event, schema=SCHEMA["ParentSchema"], handlers=test_schema_handlers)
```
52 changes: 52 additions & 0 deletions tests/functional/validator/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,53 @@ def schema_response():
}


@pytest.fixture
def schema_refs():
return {
"ParentSchema": {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "testschema://ParentSchema",
"type": "object",
"title": "Sample schema",
"description": "Sample JSON Schema that references another schema",
"examples": [{"parent_object": {"child_string": "hello world"}}],
"required": "parent_object",
"properties": {
"parent_object": {
"$id": "#/properties/parent_object",
"$ref": "testschema://ChildSchema",
},
},
},
"ChildSchema": {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "testschema://ChildSchema",
"type": "object",
"title": "Sample schema",
"description": "Sample JSON Schema that is referenced by another schema",
"examples": [{"child_string": "hello world"}],
"required": "child_string",
"properties": {
"child_string": {
"$id": "#/properties/child_string",
"type": "string",
"title": "The child string",
"examples": ["hello world"],
},
},
},
}


@pytest.fixture
def schema_ref_handlers(schema_refs):
def handle_test_schema(uri):
schema_key = uri.split("://")[1]
return schema_refs[schema_key]

return {"testschema": handle_test_schema}


@pytest.fixture
def raw_event():
return {"message": "hello hello", "username": "blah blah"}
Expand All @@ -105,6 +152,11 @@ def wrapped_event_base64_json_string():
return {"data": "eyJtZXNzYWdlIjogImhlbGxvIGhlbGxvIiwgInVzZXJuYW1lIjogImJsYWggYmxhaCJ9="}


@pytest.fixture
def parent_ref_event():
return {"parent_object": {"child_string": "hello world"}}


@pytest.fixture
def raw_response():
return {"statusCode": 200, "body": "response"}
Expand Down
4 changes: 4 additions & 0 deletions tests/functional/validator/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ def test_validate_invalid_custom_format(
)


def test_validate_custom_handlers(schema_refs, schema_ref_handlers, parent_ref_event):
validate(event=parent_ref_event, schema=schema_refs["ParentSchema"], handlers=schema_ref_handlers)


def test_validate_invalid_envelope_expression(schema, wrapped_event):
with pytest.raises(exceptions.InvalidEnvelopeExpressionError):
validate(event=wrapped_event, schema=schema, envelope=True)
Expand Down
Loading