diff --git a/.cfnlintrc.yaml b/.cfnlintrc.yaml new file mode 100644 index 00000000000..3909b9bb437 --- /dev/null +++ b/.cfnlintrc.yaml @@ -0,0 +1,2 @@ +ignore_templates: + - examples/event_handler_appsync_events/sam/getting_started_with_appsync_events.yaml diff --git a/.chglog/CHANGELOG.tpl.md b/.chglog/CHANGELOG.tpl.md new file mode 100755 index 00000000000..beb340ad645 --- /dev/null +++ b/.chglog/CHANGELOG.tpl.md @@ -0,0 +1,67 @@ + + + +{{ if .Versions -}} + +# Unreleased + +{{ if .Unreleased.CommitGroups -}} +{{ range .Unreleased.CommitGroups -}} +## {{ .Title }} + +{{ range .Commits -}} +{{ if and (not (hasPrefix .Subject "changelog rebuild")) (not (hasPrefix .Subject "layer docs update")) (not (hasPrefix .Subject "bump version to")) -}} +* {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} +{{ end -}} +{{ end }} +{{ end -}} +{{ end -}} +{{ end -}} + +{{ range .Versions }} + +## {{ if .Tag.Previous }}[{{ .Tag.Name }}]{{ else }}{{ .Tag.Name }}{{ end }} - {{ datetime "2006-01-02" .Tag.Date }} +{{ range .CommitGroups -}} + +## {{ .Title }} + +{{ range .Commits -}} +{{ if and (not (hasPrefix .Subject "changelog rebuild")) (not (hasPrefix .Subject "layer docs update")) (not (hasPrefix .Subject "bump version to")) -}} +* {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} +{{ end -}} +{{ end }} +{{ end -}} + +{{- if .RevertCommits -}} +## Reverts +{{ range .RevertCommits -}} +* {{ .Revert.Header }} +{{ end }} +{{ end -}} + +{{- if .MergeCommits -}} +## Pull Requests + +{{ range .MergeCommits -}} +* {{ .Header }} +{{ end }} +{{ end -}} + +{{- if .NoteGroups -}} +{{ range .NoteGroups -}} +## {{ .Title }} +{{ range .Notes }} +{{ .Body }} +{{ end }} +{{ end -}} +{{ end -}} +{{ end -}} + +{{- if .Versions }} +[Unreleased]: {{ .Info.RepositoryURL }}/compare/{{ $latest := index .Versions 0 }}{{ $latest.Tag.Name }}...HEAD +{{ range .Versions -}} +{{ if .Tag.Previous -}} +[{{ .Tag.Name }}]: {{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }} +{{ end -}} +{{ end -}} +{{ end -}} diff --git a/.chglog/config.yml b/.chglog/config.yml new file mode 100755 index 00000000000..9ec1c8ef31e --- /dev/null +++ b/.chglog/config.yml @@ -0,0 +1,37 @@ +style: github +template: CHANGELOG.tpl.md +info: + title: CHANGELOG + repository_url: https://github.com/aws-powertools/powertools-lambda-python +options: + commits: + filters: + Type: + - feat + - fix + - perf + - refactor + - docs + - chore + - revert + commit_groups: + title_maps: + feat: Features + fix: Bug Fixes + perf: Performance Improvements + refactor: Code Refactoring + docs: Documentation + chore: Maintenance + revert: Regression + header: + pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$" + pattern_maps: + - Type + - Scope + - Subject + notes: + keywords: + - BREAKING CHANGE + # issues: + # prefix: + # - # diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 00000000000..19d3018f3ac --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,12 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +# Copy project source +COPY . $SRC/powertools + +WORKDIR $SRC/powertools + +# Install project dependencies +RUN pip3 install -e ".[all]" + +# Copy build script +COPY .clusterfuzzlite/build.sh $SRC/ diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100644 index 00000000000..e5aadd80335 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash -eu + +# Build fuzz targets from tests/fuzz/ +for fuzzer in $(find $SRC/powertools/tests/fuzz -name 'fuzz_*.py'); do + compile_python_fuzzer "$fuzzer" +done diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml new file mode 100644 index 00000000000..de5f07bb82a --- /dev/null +++ b/.clusterfuzzlite/project.yaml @@ -0,0 +1,4 @@ +language: python +main_repo: https://github.com/aws-powertools/powertools-lambda-python +sanitizers: + - address diff --git a/.flake8 b/.flake8 index 6c0c78fa967..1db8406d9e4 100644 --- a/.flake8 +++ b/.flake8 @@ -3,6 +3,11 @@ exclude = docs, .eggs, setup.py, example, .aws-sam, .git, dist, *.md, *.yaml, ex ignore = E203, E266, W503, BLK100, W291, I004 max-line-length = 120 max-complexity = 15 +; flake8-builtins isn't honouring inline ignore (A003) +per-file-ignores = + tests/e2e/utils/data_builder/__init__.py:F401 + tests/e2e/utils/data_fetcher/__init__.py:F401 + aws_lambda_powertools/utilities/data_classes/s3_event.py:A003 [isort] multi_line_output = 3 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..1062320f5b1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +* @aws-powertools/lambda-python-core diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 43ec3443e8b..00000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug, triage -assignees: '' - ---- - - - - -**What were you trying to accomplish?** - -## Expected Behavior - - - -## Current Behavior - - - -## Possible Solution - - - -## Steps to Reproduce (for bugs) - - -1. -2. -3. -4. - -## Environment - -* **Powertools version used**: -* **Packaging format (Layers, PyPi)**: -* **AWS Lambda function runtime:** -* **Debugging logs** - -> [How to enable debug mode](https://awslabs.github.io/aws-lambda-powertools-python/#debug-mode)** - -```python -# paste logs here -``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..21a8f3b035c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,92 @@ +name: Bug report +description: Report a reproducible bug to help us improve +title: "Bug: TITLE" +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for submitting a bug report. Please add as much information as possible to help us reproduce, and remove any potential sensitive data. + + Please become familiar with [our definition of bug](https://github.com/aws-powertools/powertools-lambda-python/blob/develop/MAINTAINERS.md#is-that-a-bug). + - type: textarea + id: expected_behaviour + attributes: + label: Expected Behaviour + description: Please share details on the behaviour you expected + validations: + required: true + - type: textarea + id: current_behaviour + attributes: + label: Current Behaviour + description: Please share details on the current issue + validations: + required: true + - type: textarea + id: code_snippet + attributes: + label: Code snippet + description: Please share a code snippet to help us reproduce the issue + render: python + validations: + required: true + - type: textarea + id: solution + attributes: + label: Possible Solution + description: If known, please suggest a potential resolution + validations: + required: false + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Please share how we might be able to reproduce this issue + validations: + required: true + - type: input + id: version + attributes: + label: Powertools for AWS Lambda (Python) version + placeholder: "latest, 1.25.6" + value: latest + validations: + required: true + - type: dropdown + id: runtime + attributes: + label: AWS Lambda function runtime + options: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + validations: + required: true + - type: dropdown + id: packaging + attributes: + label: Packaging format used + options: + - Lambda Layers + - Serverless Application Repository (SAR) App + - PyPi + multiple: true + validations: + required: true + - type: textarea + id: logs + attributes: + label: Debugging logs + description: If available, please share [debugging logs](https://docs.powertools.aws.dev/lambda/python/#debug-mode) + render: python + validations: + required: false + - type: markdown + attributes: + value: | + --- + + **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..8acf5081f3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/aws-powertools/powertools-lambda-python/discussions/new + about: Ask a general question about Lambda Powertools diff --git a/.github/ISSUE_TEMPLATE/documentation-improvements.md b/.github/ISSUE_TEMPLATE/documentation-improvements.md deleted file mode 100644 index 8341ae4e0c5..00000000000 --- a/.github/ISSUE_TEMPLATE/documentation-improvements.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Documentation improvements -about: Suggest a documentation update -title: '' -labels: documentation -assignees: '' - ---- - -**What were you initially searching for in the docs?** - - -**Is this related to an existing part of the documentation? Please share a link** - -**Describe how we could make it clearer** - -**If you have a proposed update, please share it here** diff --git a/.github/ISSUE_TEMPLATE/documentation_improvements.yml b/.github/ISSUE_TEMPLATE/documentation_improvements.yml new file mode 100644 index 00000000000..e750d51923f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_improvements.yml @@ -0,0 +1,50 @@ +name: Documentation improvements +description: Suggest a documentation update to improve everyone's experience +title: "Docs: TITLE" +labels: ["documentation", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for helping us improve everyone's experience. We review documentation updates on a case by case basis. + - type: textarea + id: search_area + attributes: + label: What were you searching in the docs? + description: Please help us understand how you looked for information that was either unclear or not available + validations: + required: true + - type: input + id: area + attributes: + label: Is this related to an existing documentation section? + description: Please share a link, if applicable + validations: + required: false + - type: textarea + id: idea + attributes: + label: How can we improve? + description: Please share your thoughts on how we can improve this experience + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Got a suggestion in mind? + description: Please suggest a proposed update + validations: + required: false + - type: checkboxes + id: acknowledgment + attributes: + label: Acknowledgment + options: + - label: I understand the final update might be different from my proposed suggestion, or refused. + required: true + - type: markdown + attributes: + value: | + --- + + **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index b837b7ad5a1..00000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: feature-request, triage -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** - - -**Describe the solution you'd like** - - -**Describe alternatives you've considered** - - -**Additional context** - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..a39fb211ac1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,48 @@ +name: Feature request +description: Suggest an idea for Powertools for AWS Lambda (Python) +title: "Feature request: TITLE" +labels: ["feature-request", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to suggest an idea to the Powertools for AWS Lambda (Python) project. + + *Future readers*: Please react with 👍 and your use case to help us understand customer demand. + - type: textarea + id: problem + attributes: + label: Use case + description: Please help us understand your use case or problem you're facing + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Solution/User Experience + description: Please share what a good solution would look like to this use case + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternative solutions + description: Please describe what alternative solutions to this use case, if any + render: markdown + validations: + required: false + - type: checkboxes + id: acknowledgment + attributes: + label: Acknowledgment + options: + - label: This feature request meets [Powertools for AWS Lambda (Python) Tenets](https://docs.powertools.aws.dev/lambda/python/latest/#tenets) + required: true + - label: Should this be considered in other Powertools for AWS Lambda languages? i.e. [Java](https://github.com/aws-powertools/powertools-lambda-java/), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript/), and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet/) + required: false + - type: markdown + attributes: + value: | + --- + + **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. diff --git a/.github/ISSUE_TEMPLATE/maintenance.yml b/.github/ISSUE_TEMPLATE/maintenance.yml new file mode 100644 index 00000000000..bbc3ff3b3d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/maintenance.yml @@ -0,0 +1,65 @@ +name: Maintenance +description: Suggest an activity to help address governance and anything internal +title: "Maintenance: TITLE" +labels: ["internal", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to help us improve operational excellence. + + *Future readers*: Please react with 👍 and your use case to help us understand customer demand. + - type: textarea + id: importance + attributes: + label: Why is this needed? + description: Please help us understand the value so we can prioritize it accordingly + validations: + required: true + - type: dropdown + id: area + attributes: + label: Which area does this relate to? + multiple: true + options: + - Tests + - Static typing + - Tracer + - Logger + - Metrics + - Event Handler - REST API + - Event Handler - GraphQL API + - Middleware factory + - Parameters + - Batch processing + - Validation + - Event Source Data Classes + - Parser + - Idempotency + - Feature flags + - JMESPath functions + - Streaming + - Automation + - Other + - type: textarea + id: suggestion + attributes: + label: Solution + description: If available, please share what a good solution would look like + validations: + required: false + - type: checkboxes + id: acknowledgment + attributes: + label: Acknowledgment + options: + - label: This request meets [Powertools for AWS Lambda (Python) Tenets](https://docs.powertools.aws.dev/lambda/python/latest/#tenets) + required: true + - label: Should this be considered in other Powertools for AWS Lambda languages? i.e. [Java](https://github.com/aws-powertools/powertools-lambda-java/), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript/), and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet/) + required: false + - type: markdown + attributes: + value: | + --- + + **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. diff --git a/.github/ISSUE_TEMPLATE/rfc.md b/.github/ISSUE_TEMPLATE/rfc.md deleted file mode 100644 index 4b666a16800..00000000000 --- a/.github/ISSUE_TEMPLATE/rfc.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: RFC -about: Feature design and proposals -title: 'RFC: ' -labels: RFC, triage -assignees: '' - ---- - -## Key information - -* RFC PR: (leave this empty) -* Related issue(s), if known: -* Area: (i.e. Tracer, Metrics, Logger, etc.) -* Meet [tenets](https://awslabs.github.io/aws-lambda-powertools-python/#tenets): (Yes/no) - -## Summary -[summary]: #summary - -> One paragraph explanation of the feature. - -## Motivation -[motivation]: #motivation - -> Why are we doing this? What use cases does it support? What is the expected outcome? - -## Proposal -[proposal]: #proposal - -> This is the bulk of the RFC. - -> Explain the design in enough detail for somebody familiar with Powertools to understand it, and for somebody familiar with the implementation to implement it. - -> This should get into specifics and corner-cases, and include examples of how the feature is used. Any new terminology should be defined here. - -## Drawbacks -[drawbacks]: #drawbacks - -> Why should we *not* do this? - -> Do we need additional dependencies? Impact performance/package size? - -## Rationale and alternatives -[rationale-and-alternatives]: #rationale-and-alternatives - -* **What other designs have been considered? Why not them?** -* **What is the impact of not doing this?** - -## Unresolved questions -[unresolved-questions]: #unresolved-questions - -> Optional, stash area for topics that need further development e.g. TBD diff --git a/.github/ISSUE_TEMPLATE/rfc.yml b/.github/ISSUE_TEMPLATE/rfc.yml new file mode 100644 index 00000000000..31d8d7fe0c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rfc.yml @@ -0,0 +1,109 @@ +name: Request for Comments (RFC) +description: Feature design and detailed proposals +title: "RFC: TITLE" +labels: ["RFC", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for submitting a RFC. Please add as many details as possible to help further enrich this design. + - type: input + id: relation + attributes: + label: Is this related to an existing feature request or issue? + description: Please share a link, if applicable + - type: dropdown + id: area + attributes: + label: Which Powertools for AWS Lambda (Python) utility does this relate to? + options: + - Tracer + - Logger + - Metrics + - Event Handler - REST API + - Event Handler - GraphQL API + - Middleware factory + - Parameters + - Batch processing + - Typing + - Validation + - Event Source Data Classes + - Parser + - Idempotency + - Feature flags + - JMESPath functions + - Other + validations: + required: true + - type: textarea + id: summary + attributes: + label: Summary + description: Please provide an overview in one or two paragraphs + validations: + required: true + - type: textarea + id: problem + attributes: + label: Use case + description: Please share the use case and motivation behind this proposal + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: Please explain the design in detail, so anyone familiar with the project could implement it + placeholder: What the user experience looks like before and after this design? + validations: + required: true + - type: textarea + id: scope + attributes: + label: Out of scope + description: Please explain what should be considered out of scope in your proposal + validations: + required: true + - type: textarea + id: challenges + attributes: + label: Potential challenges + description: Nothing is perfect. Please share what common challenges, edge cases, unresolved areas, and suggestions on how to mitigate them + validations: + required: true + - type: textarea + id: integrations + attributes: + label: Dependencies and Integrations + description: If applicable, please share whether this feature has additional dependencies, and how it might integrate with other utilities available + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Alternative solutions + description: Please describe what alternative solutions to this use case, if any + render: markdown + validations: + required: false + - type: checkboxes + id: acknowledgment + attributes: + label: Acknowledgment + options: + - label: This feature request meets [Powertools for AWS Lambda (Python) Tenets](https://docs.powertools.aws.dev/lambda/python/latest/#tenets) + required: true + - label: Should this be considered in other Powertools for AWS Lambda languages? i.e. [Java](https://github.com/aws-powertools/powertools-lambda-java/), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript/), and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet/) + required: false + - type: markdown + attributes: + value: | + --- + + **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. + + Metadata information for admin purposes, please leave them empty. + + * RFC PR: + * Approved by: '' + * Reviewed by: '' diff --git a/.github/ISSUE_TEMPLATE/share_your_work.yml b/.github/ISSUE_TEMPLATE/share_your_work.yml new file mode 100644 index 00000000000..e4e4ed601be --- /dev/null +++ b/.github/ISSUE_TEMPLATE/share_your_work.yml @@ -0,0 +1,56 @@ +name: I Made This (showcase your work) +description: Share what you did with Powertools for AWS Lambda (Python) 💞💞. Blog post, workshops, presentation, sample apps, etc. +title: "[I Made This]:
")
+ def get_path(p: Annotated[str_field, Path()]):
+ return {"value": p}
+
+ @app.get("/query")
+ def get_query(q: Annotated[str_field, Query()]):
+ return {"value": q}
+
+ @app.post("/body")
+ def post_body(b: Annotated[str_field, Body()]):
+ return {"value": b}
+
+ del gw_event["multiValueHeaders"]
+ del gw_event["multiValueQueryStringParameters"]
+
+ # Header
+ gw_event["path"] = "/header"
+ gw_event["httpMethod"] = "GET"
+ gw_event["headers"] = {"h": "test"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Path
+ gw_event["path"] = "/path/test"
+ gw_event["pathParameters"] = {"p": "test"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Query
+ gw_event["path"] = "/query"
+ gw_event["pathParameters"] = None
+ gw_event["queryStringParameters"] = {"q": "test"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Body
+ gw_event["path"] = "/body"
+ gw_event["httpMethod"] = "POST"
+ gw_event["headers"]["content-type"] = "application/json"
+ gw_event["body"] = '"test"'
+ assert app(gw_event, {})["statusCode"] == 200
+
+
+def test_field_constraints_apply_with_param_type(gw_event):
+ """Constraints declared on a Field are enforced when paired with a location marker."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/items")
+ def get_items(quantity: Annotated[int, Field(gt=0), Query()]):
+ return {"quantity": quantity}
+
+ gw_event["path"] = "/items"
+ gw_event["httpMethod"] = "GET"
+
+ # Passes the gt=0 constraint
+ gw_event["queryStringParameters"] = {"quantity": "5"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Violates gt=0
+ gw_event["queryStringParameters"] = {"quantity": "-1"}
+ assert app(gw_event, {})["statusCode"] == 422
+
+
+def test_validate_pydantic_query_params_with_config_dict_and_validators(gw_event):
+ """Test that Pydantic models with ConfigDict, aliases, and validators work correctly"""
+
+ del gw_event["multiValueHeaders"]
+ del gw_event["multiValueQueryStringParameters"]
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ def _validate_powertools(value: str) -> str:
+ if not value.startswith("Powertools"):
+ raise ValueError("Full name must start with 'Powertools'")
+ return value
+
+ class QuerySimple(BaseModel):
+ full_name: Annotated[str, StringConstraints(min_length=5), AfterValidator(_validate_powertools)]
+ next_token: Base64UrlStr
+ search_id: str
+
+ @app.get("/query-model-simple")
+ def query_model(params: Annotated[QuerySimple, Query()]) -> Dict[str, Any]:
+ return {
+ "fullName": params.full_name,
+ "nextToken": params.next_token,
+ "searchId": params.search_id,
+ }
+
+ class QueryAdvanced(BaseModel):
+ full_name: Annotated[str, StringConstraints(min_length=5)]
+ next_token: str
+ search_id: Annotated[str, Field(alias="id")] # Using str instead of UUID4 for simpler testing
+
+ model_config = ConfigDict(
+ alias_generator=alias_generators.to_camel,
+ validate_by_alias=True,
+ validate_by_name=True,
+ serialize_by_alias=True,
+ )
+
+ @app.get("/query-model-advanced")
+ def query_model_advanced(params: Annotated[QueryAdvanced, Query()]) -> Dict[str, Any]:
+ return params.model_dump()
+
+ # Test QuerySimple with validators
+ gw_event["path"] = "/query-model-simple"
+ gw_event["queryStringParameters"] = {
+ "full_name": "Powertools Lambda",
+ "next_token": "dGVzdA==", # base64url encoded "test"
+ "search_id": "search-123",
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ body = json.loads(result["body"])
+ assert body["fullName"] == "Powertools Lambda"
+ assert body["nextToken"] == "test"
+ assert body["searchId"] == "search-123"
+
+ # Test QuerySimple validation error (name doesn't start with "Powertools")
+ gw_event["queryStringParameters"] = {
+ "full_name": "Lambda Powertools",
+ "next_token": "dGVzdA==",
+ "search_id": "search-123",
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+ body = json.loads(result["body"])
+ assert "detail" in body
+ errors = body["detail"]
+
+ # Should have validation error for full_name with proper location
+ full_name_error = next((e for e in errors if "full_name" in e["loc"]), None)
+
+ assert full_name_error is not None, "Should have error for full_name field"
+
+ # Check error details for full_name
+ assert full_name_error["loc"] == ["query", "params", "full_name"]
+ assert full_name_error["type"] == "value_error"
+
+ # Test QueryAdvanced with ConfigDict and alias_generator
+ gw_event["path"] = "/query-model-advanced"
+ gw_event["queryStringParameters"] = {
+ "fullName": "Advanced Test", # camelCase from alias_generator
+ "nextToken": "dGVzdA==", # camelCase from alias_generator
+ "id": "search-456", # explicit alias
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ body = json.loads(result["body"])
+ # Should return with camelCase keys due to serialize_by_alias=True
+ assert body["fullName"] == "Advanced Test"
+ assert body["nextToken"] == "dGVzdA=="
+ assert body["id"] == "search-456"
+
+ # Test QueryAdvanced with snake_case field names due to validate_by_name=True
+ gw_event["queryStringParameters"] = {
+ "full_name": "Snake Case Test", # snake_case field name
+ "next_token": "dGVzdA==", # snake_case field name
+ "search_id": "search-789", # snake_case field name
+ }
+
+ gw_event["path"] = "/query-model-advanced"
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ body = json.loads(result["body"])
+ assert body["fullName"] == "Snake Case Test"
+ assert body["nextToken"] == "dGVzdA=="
+ assert body["id"] == "search-789"
+
+ # Test QueryAdvanced validation error (full_name too short)
+ gw_event["queryStringParameters"] = {
+ "fullName": "Bad", # Too short (min_length=5)
+ "nextToken": "dGVzdA==",
+ "id": "search-456",
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+ body = json.loads(result["body"])
+ assert "detail" in body
+ errors = body["detail"]
+
+ # Should have validation error for full_name with proper location
+ full_name_error = next((e for e in errors if "full_name" in e["loc"] or "fullName" in e["loc"]), None)
+ assert full_name_error is not None
+ assert full_name_error["type"] == "string_too_short"
+
+
+def test_validation_query_string_with_fully_encoded_datetime_alb_resolver():
+ # GIVEN a ALBResolver with validation enabled,
+ # and an event with a fully url-encoded datetime
+ # as a query string parameter
+ app = ALBResolver(enable_validation=True, decode_query_parameters=True)
+ raw_event = load_event("albEvent.json")
+ raw_event["path"] = "/users"
+ # Fully encoded: "2025-12-20T16:56:02.032000" -> "2025-12-20T16%3A56%3A02.032000"
+ # With spaces or special chars: "2025-12-20 16:56:02" -> "2025-12-20%2016%3A56%3A02"
+ raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
+
+ @app.get("/users")
+ def handler(query_dt: datetime.datetime):
+ return {"received": query_dt.isoformat()}
+
+ result = app(raw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["received"] == "2025-12-20T16:56:02.032000"
+
+
+def test_validation_query_string_with_encoded_key_and_value_alb_resolver():
+ # GIVEN a ALBResolver with validation enabled,
+ # and an event with url-encoded key AND value
+ app = ALBResolver(enable_validation=True, decode_query_parameters=True)
+ raw_event = load_event("albEvent.json")
+ raw_event["path"] = "/search"
+ # Key: "search query" -> "search%20query"
+ # Value: "hello world" -> "hello%20world"
+ raw_event["queryStringParameters"] = {"search%20query": "hello%20world"}
+
+ @app.get("/search")
+ def handler(search_query: Annotated[str, Query(alias="search query")]):
+ return {"result": search_query}
+
+ result = app(raw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["result"] == "hello world"
+
+
+def test_validation_without_decode_query_parameters_alb_resolver():
+ # GIVEN a ALBResolver WITHOUT decode_query_parameters (default behavior)
+ app = ALBResolver(enable_validation=True)
+ raw_event = load_event("albEvent.json")
+ raw_event["path"] = "/users"
+ raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
+
+ @app.get("/users")
+ def handler(query_dt: datetime.datetime):
+ return None
+
+ # THEN validation should fail because the encoded string is not a valid datetime
+ result = app(raw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_validate_union_single_or_list_body_with_list(gw_event):
+ """Test that Union[Model, List[Model]] correctly handles a list of items"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+ value: int
+
+ # WHEN a handler is defined with Union[Model, List[Model]] body parameter
+ @app.post("/items")
+ def handler(items: Annotated[Union[Item, List[Item]], Body()]) -> Dict[str, Any]:
+ # Should receive the full list, not just the first element
+ if isinstance(items, list):
+ return {"count": len(items), "items": [item.model_dump() for item in items]}
+ else:
+ return {"count": 1, "items": [items.model_dump()]}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/items"
+ # Send a list of items
+ gw_event["body"] = json.dumps(
+ [
+ {"name": "item1", "value": 10},
+ {"name": "item2", "value": 20},
+ {"name": "item3", "value": 30},
+ ],
+ )
+
+ # THEN the handler should receive all items in the list, not just the first one
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["count"] == 3
+ assert len(body["items"]) == 3
+ assert body["items"][0]["name"] == "item1"
+ assert body["items"][1]["name"] == "item2"
+ assert body["items"][2]["name"] == "item3"
+
+
+def test_validate_union_single_or_list_body_with_single(gw_event):
+ """Test that Union[Model, List[Model]] correctly handles a single item"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+ value: int
+
+ # WHEN a handler is defined with Union[Model, List[Model]] body parameter
+ @app.post("/items")
+ def handler(items: Annotated[Union[Item, List[Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"count": len(items), "items": [item.model_dump() for item in items]}
+ else:
+ return {"count": 1, "items": [items.model_dump()]}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/items"
+ # Send a single item
+ gw_event["body"] = json.dumps({"name": "single_item", "value": 42})
+
+ # THEN the handler should receive the single item
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["count"] == 1
+ assert len(body["items"]) == 1
+ assert body["items"][0]["name"] == "single_item"
+ assert body["items"][0]["value"] == 42
+
+
+def test_validate_rootmodel_list_body(gw_event):
+ """Test that RootModel[List[Model]] correctly handles a list of items"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+ value: int
+
+ class ItemCollection(RootModel[List[Item]]):
+ root: List[Item]
+
+ # WHEN a handler is defined with RootModel[List[Model]] body parameter
+ @app.post("/items")
+ def handler(collection: Annotated[ItemCollection, Body()]) -> Dict[str, Any]:
+ # collection.root should contain the full list
+ items = collection.root
+ return {"count": len(items), "items": [item.model_dump() for item in items]}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/items"
+ # Send a list of items
+ gw_event["body"] = json.dumps(
+ [
+ {"name": "item1", "value": 100},
+ {"name": "item2", "value": 200},
+ ],
+ )
+
+ # THEN the handler should receive all items in the collection
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["count"] == 2
+ assert len(body["items"]) == 2
+ assert body["items"][0]["name"] == "item1"
+ assert body["items"][0]["value"] == 100
+ assert body["items"][1]["name"] == "item2"
+ assert body["items"][1]["value"] == 200
+
+
+def test_validate_nested_union_with_sequence(gw_event):
+ """Test that nested Union types containing sequences are handled correctly"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Person(BaseModel):
+ name: str
+ age: int
+
+ # WHEN a handler is defined with a complex Union including List
+ @app.post("/people")
+ def handler(
+ data: Annotated[Union[str, List[Person], Person], Body()],
+ ) -> Dict[str, Any]:
+ if isinstance(data, str):
+ return {"type": "string", "value": data}
+ elif isinstance(data, list):
+ return {"type": "list", "count": len(data)}
+ else:
+ return {"type": "person", "name": data.name}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/people"
+ # Send a list
+ gw_event["body"] = json.dumps(
+ [
+ {"name": "Alice", "age": 30},
+ {"name": "Bob", "age": 25},
+ ],
+ )
+
+ # THEN the handler should receive the full list
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["type"] == "list"
+ assert body["count"] == 2
+
+
+# ────────────────────────────────────────────────────────────────────
+# Regression tests for Union / RootModel / Optional sequence body
+# See: https://github.com/aws-powertools/powertools-lambda-python/issues/8057
+# ────────────────────────────────────────────────────────────────────
+
+
+class _Item(BaseModel):
+ name: str
+ value: int
+
+
+class _ItemCollection(RootModel[List[_Item]]):
+ pass
+
+
+_THREE_ITEMS = [
+ {"name": "a", "value": 1},
+ {"name": "b", "value": 2},
+ {"name": "c", "value": 3},
+]
+
+
+def _post_json(app, path, payload):
+ """Helper: build a minimal APIGW REST event, POST JSON, return parsed result."""
+ from tests.functional.utils import load_event
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "POST"
+ event["path"] = path
+ event["body"] = json.dumps(payload)
+ result = app(event, {})
+ return result["statusCode"], json.loads(result["body"])
+
+
+# ---------- List[Model] | None ----------
+
+
+def test_optional_list_body_with_list():
+ """List[Model] | None must preserve the full list."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item] | None, Body()]) -> Dict[str, Any]:
+ assert isinstance(items, list)
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_optional_list_body_with_none():
+ """List[Model] | None must accept a null body gracefully."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item] | None, Body()] = None) -> Dict[str, Any]:
+ return {"received_none": items is None}
+
+ status, body = _post_json(app, "/items", None)
+ assert status == 200
+ assert body["received_none"] is True
+
+
+# ---------- Union[Model, List[Model]] | None ----------
+
+
+def test_optional_union_model_or_list_with_list():
+ """Union[Model, List[Model]] | None — send list, get full list."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]] | None, Body()]) -> Dict[str, Any]:
+ assert isinstance(items, list)
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_optional_union_model_or_list_with_single():
+ """Union[Model, List[Model]] | None — send single obj, get single obj."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]] | None, Body()]) -> Dict[str, Any]:
+ assert not isinstance(items, list)
+ return {"name": items.name}
+
+ status, body = _post_json(app, "/items", {"name": "solo", "value": 99})
+ assert status == 200
+ assert body["name"] == "solo"
+
+
+def test_optional_union_model_or_list_with_none():
+ """Union[Model, List[Model]] | None — send null, get None."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]] | None, Body()] = None) -> Dict[str, Any]:
+ return {"is_none": items is None}
+
+ status, body = _post_json(app, "/items", None)
+ assert status == 200
+ assert body["is_none"] is True
+
+
+# ---------- List[Model] directly (no Union / Optional) ----------
+
+
+def test_plain_list_body_preserves_all_items():
+ """List[Model] — baseline: must never truncate."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item], Body()]) -> Dict[str, Any]:
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+# ---------- Empty list ----------
+
+
+def test_union_model_or_list_with_empty_list():
+ """Union[Model, List[Model]] with [] — must not crash on value[0]."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"count": len(items)}
+ return {"count": 1}
+
+ status, body = _post_json(app, "/items", [])
+ assert status == 200
+ assert body["count"] == 0
+
+
+def test_plain_list_with_empty_list():
+ """List[Model] with [] — must accept empty list."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item], Body()]) -> Dict[str, Any]:
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", [])
+ assert status == 200
+ assert body["count"] == 0
+
+
+# ---------- Single-element list (boundary) ----------
+
+
+def test_union_model_or_list_with_single_element_list():
+ """Union[Model, List[Model]] with [single_item] — must NOT unwrap to scalar."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"type": "list", "count": len(items)}
+ return {"type": "single"}
+
+ status, body = _post_json(app, "/items", [{"name": "only", "value": 1}])
+ assert status == 200
+ # Pydantic may match as single Item or list — either is valid,
+ # but it must NOT crash or lose data
+ assert body.get("count", 1) == 1
+
+
+# ---------- Union with primitive sequences ----------
+
+
+def test_union_str_or_list_dict():
+ """Union[str, List[dict]] — list of dicts must arrive intact."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/data")
+ def handler(data: Annotated[Union[str, List[Dict[str, Any]]], Body()]) -> Dict[str, Any]:
+ if isinstance(data, list):
+ return {"type": "list", "count": len(data)}
+ return {"type": "str"}
+
+ payload = [{"key": "v1"}, {"key": "v2"}]
+ status, body = _post_json(app, "/data", payload)
+ assert status == 200
+ assert body["type"] == "list"
+ assert body["count"] == 2
+
+
+# ---------- RootModel edge cases ----------
+
+
+def test_optional_rootmodel_list_body():
+ """RootModel[List[Model]] | None — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[_ItemCollection | None, Body()]) -> Dict[str, Any]:
+ return {"count": len(items.root)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_union_rootmodel_and_model():
+ """Union[RootModel[List[Model]], Model] — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_ItemCollection, _Item], Body()]) -> Dict[str, Any]:
+ if isinstance(items, _ItemCollection):
+ return {"type": "collection", "count": len(items.root)}
+ return {"type": "single", "name": items.name}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["type"] == "collection"
+ assert body["count"] == 3
+
+
+# ---------- Python 3.10+ pipe Union syntax ----------
+
+
+def test_pipe_union_syntax_model_or_list():
+ """Model | List[Model] (PEP 604 syntax) — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[_Item | List[_Item], Body()]) -> Dict[str, Any]: # noqa: FA102
+ if isinstance(items, list):
+ return {"count": len(items)}
+ return {"count": 1}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_pipe_union_optional_list():
+ """List[Model] | None (PEP 604 Optional) — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item] | None, Body()]) -> Dict[str, Any]: # noqa: FA102
+ if items is None:
+ return {"count": 0}
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+# ---------- Deeply nested: RootModel[Union[Model, List[Model]]] ----------
+
+
+def test_rootmodel_wrapping_union_with_sequence():
+ """RootModel[Union[Model, List[Model]]] — inner Union sequence must be detected."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class FlexiblePayload(RootModel[Union[_Item, List[_Item]]]):
+ pass
+
+ @app.post("/items")
+ def handler(payload: Annotated[FlexiblePayload, Body()]) -> Dict[str, Any]:
+ data = payload.root
+ if isinstance(data, list):
+ return {"type": "list", "count": len(data)}
+ return {"type": "single", "name": data.name}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["type"] == "list"
+ assert body["count"] == 3
+
+
+# ---------- Multiple resolvers (ALB, HTTP API, etc.) ----------
+
+
+def test_union_list_body_works_across_resolvers():
+ """Regression: ensure fix works for ALB and HTTP API resolvers too."""
+ for ResolverClass in [APIGatewayHttpResolver, ALBResolver]:
+ app = ResolverClass(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"count": len(items)}
+ return {"count": 1}
+
+ # Build event appropriate for resolver
+ if ResolverClass is APIGatewayHttpResolver:
+ event = load_event("apiGatewayProxyV2Event.json")
+ event["requestContext"]["http"]["method"] = "POST"
+ event["requestContext"]["http"]["path"] = "/items"
+ event["rawPath"] = "/items"
+ else:
+ event = load_event("albEvent.json")
+ event["httpMethod"] = "POST"
+ event["path"] = "/items"
+
+ event["body"] = json.dumps(_THREE_ITEMS)
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ body_result = json.loads(result["body"])
+ assert body_result["count"] == 3, f"Failed for {ResolverClass.__name__}"
+
+
+# ---------- Large list (stress boundary) ----------
+
+
+def test_union_list_body_large_payload():
+ """Union[Model, List[Model]] with 100 items — no truncation."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ assert isinstance(items, list)
+ return {"count": len(items)}
+
+ big_payload = [{"name": f"item-{i}", "value": i} for i in range(100)]
+ status, body = _post_json(app, "/items", big_payload)
+ assert status == 200
+ assert body["count"] == 100
+
+
+# ---------- File upload (multipart/form-data) ----------
+
+
+def _build_multipart_body(fields: List[Dict], boundary: str = "----TestBoundary") -> Tuple[str, str]:
+ """
+ Build a multipart/form-data body and return (base64_body, content_type).
+
+ Each field dict can have:
+ - name: field name (required)
+ - value: str or bytes (required)
+ - filename: optional filename (makes it a file part)
+ - content_type: optional content type for the part
+ """
+ parts = []
+ for field in fields:
+ headers = f'Content-Disposition: form-data; name="{field["name"]}"'
+ if "filename" in field:
+ headers += f'; filename="{field["filename"]}"'
+ if "content_type" in field:
+ headers += f"\r\nContent-Type: {field['content_type']}"
+ value = field["value"]
+ if isinstance(value, str):
+ value = value.encode("utf-8")
+ parts.append((headers, value))
+
+ body = b""
+ for headers, value in parts:
+ body += f"--{boundary}\r\n".encode()
+ body += f"{headers}\r\n\r\n".encode()
+ body += value
+ body += b"\r\n"
+ body += f"--{boundary}--\r\n".encode()
+
+ content_type = f"multipart/form-data; boundary={boundary}"
+ return base64.b64encode(body).decode("utf-8"), content_type
+
+
+def test_file_upload_basic(gw_event):
+ """Test basic file upload with File() parameter."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "file_data", "value": b"hello world", "filename": "test.txt"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": 11}
+
+
+def test_file_upload_with_form_field(gw_event):
+ """Test file upload mixed with a regular form field."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(
+ description: Annotated[str, Form()],
+ file_data: Annotated[bytes, File()],
+ ):
+ return {"description": description, "size": len(file_data)}
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "description", "value": "my file"},
+ {"name": "file_data", "value": b"\x89PNG\r\n\x1a\n", "filename": "image.png", "content_type": "image/png"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["description"] == "my file"
+ assert parsed["size"] == 8
+
+
+def test_file_upload_missing_required(gw_event):
+ """Test that missing required File() parameter returns 422."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ # Send empty multipart body (no file_data field)
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "other_field", "value": "some value"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+ assert "missing" in result["body"]
+
+
+def test_file_upload_openapi_schema():
+ """Test that File() parameters generate correct OpenAPI schema."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File(description="The file to upload")]):
+ return {"size": len(file_data)}
+
+ schema = app.get_openapi_schema()
+ path = schema.paths["/upload"]
+ post_op = path.post
+
+ # Should have a request body with multipart/form-data
+ assert post_op.requestBody is not None
+ content = post_op.requestBody.content
+ assert "multipart/form-data" in content
+
+ # The schema should reference a binary format field
+ multipart_schema = content["multipart/form-data"].schema_
+ assert multipart_schema is not None
+
+
+def test_file_upload_non_base64(gw_event):
+ """Test file upload when body is not base64-encoded (edge case)."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ # Build multipart body without base64 encoding
+ boundary = "----TestBoundary"
+ raw_body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="test.txt"\r\n'
+ f"\r\n"
+ f"hello world\r\n"
+ f"--{boundary}--\r\n"
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = raw_body
+ gw_event["isBase64Encoded"] = False
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": 11}
+
+
+def test_file_upload_non_base64_emits_warning(gw_event):
+ """Test that non-base64 multipart body emits a warning about API Gateway config."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ boundary = "----TestBoundary"
+ raw_body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="test.txt"\r\n'
+ f"\r\n"
+ f"hello world\r\n"
+ f"--{boundary}--\r\n"
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = raw_body
+ gw_event["isBase64Encoded"] = False
+
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ result = app(gw_event, {})
+
+ assert result["statusCode"] == 200
+ assert len(w) == 1
+ assert "Binary Media Types" in str(w[0].message)
+
+
+def test_file_upload_non_base64_binary_content(gw_event):
+ """Test file upload with raw binary bytes (e.g. JPEG) without base64 encoding."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ # Simulate binary content with bytes that are NOT valid UTF-8 (like JPEG header 0xFF 0xD8)
+ binary_content = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00"
+ boundary = "----TestBoundary"
+ raw_bytes = (
+ (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="photo.jpg"\r\n'
+ f"Content-Type: image/jpeg\r\n"
+ f"\r\n"
+ ).encode("latin-1")
+ + binary_content
+ + f"\r\n--{boundary}--\r\n".encode("latin-1")
+ )
+
+ # Without binary mode, API Gateway passes body as latin-1 compatible string
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = raw_bytes.decode("latin-1")
+ gw_event["isBase64Encoded"] = False
+
+ with warnings.catch_warnings(record=True):
+ warnings.simplefilter("always")
+ result = app(gw_event, {})
+
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": len(binary_content)}
+
+
+def test_upload_file_with_metadata(gw_event):
+ """Test UploadFile annotation provides filename and content_type."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {
+ "filename": file_data.filename,
+ "content_type": file_data.content_type,
+ "size": len(file_data),
+ }
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "file_data", "value": b"fake jpeg", "filename": "photo.jpg", "content_type": "image/jpeg"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["filename"] == "photo.jpg"
+ assert parsed["content_type"] == "image/jpeg"
+ assert parsed["size"] == 9
+
+
+def test_upload_file_mixed_with_form(gw_event):
+ """Test UploadFile + Form fields together."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(
+ file_data: Annotated[UploadFile, File()],
+ title: Annotated[str, Form()],
+ ):
+ return {
+ "title": title,
+ "filename": file_data.filename,
+ "size": len(file_data),
+ }
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "title", "value": "My Document"},
+ {
+ "name": "file_data",
+ "value": b"pdf content here",
+ "filename": "doc.pdf",
+ "content_type": "application/pdf",
+ },
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["title"] == "My Document"
+ assert parsed["filename"] == "doc.pdf"
+ assert parsed["size"] == 16
+
+
+def test_upload_file_openapi_schema():
+ """Test UploadFile generates correct OpenAPI schema."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File(description="A file")]):
+ return {}
+
+ schema = app.get_openapi_schema()
+ schema_dict = schema.model_dump(exclude_none=True, by_alias=True)
+ upload_path = schema_dict["paths"]["/upload"]["post"]
+ content = upload_path["requestBody"]["content"]
+ assert "multipart/form-data" in content
+
+ # Resolve $ref to get the actual schema
+ ref = content["multipart/form-data"]["schema"]["$ref"]
+ schema_name = ref.split("/")[-1]
+ props = schema_dict["components"]["schemas"][schema_name]["properties"]
+ assert props["file_data"]["type"] == "string"
+ assert props["file_data"]["format"] == "binary"
+
+
+def test_multipart_missing_boundary(gw_event):
+ """Test that missing boundary in content-type raises ValueError."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = "multipart/form-data" # no boundary
+ gw_event["body"] = base64.b64encode(b"some data").decode()
+ gw_event["isBase64Encoded"] = True
+
+ with pytest.raises(ValueError, match="Missing boundary"):
+ app(gw_event, {})
+
+
+def test_multipart_quoted_boundary(gw_event):
+ """Test that boundary with quotes is parsed correctly."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ boundary = "----TestBoundary"
+ body, _ = _build_multipart_body(
+ [
+ {"name": "file_data", "value": b"hello", "filename": "test.txt"},
+ ],
+ boundary=boundary,
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ # Use quoted boundary
+ gw_event["headers"]["content-type"] = f'multipart/form-data; boundary="{boundary}"'
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": 5}
+
+
+def test_multipart_multiple_values_same_field(gw_event):
+ """Test multiple values for the same field name are collected as list."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[List[UploadFile], File()]):
+ return {"count": len(file_data), "filenames": [f.filename for f in file_data]}
+
+ # Build body with two parts having the same field name
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r\n'
+ f"\r\n"
+ f"content a\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r\n'
+ f"\r\n"
+ f"content b\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["count"] == 2
+ assert parsed["filenames"] == ["a.txt", "b.txt"]
+
+
+def test_multipart_three_values_same_field(gw_event):
+ """Test three or more values for same field name builds onto existing list."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[List[UploadFile], File()]):
+ return {"count": len(file_data), "filenames": [f.filename for f in file_data]}
+
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r\n'
+ f"\r\n"
+ f"aaa\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r\n'
+ f"\r\n"
+ f"bbb\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="c.txt"\r\n'
+ f"\r\n"
+ f"ccc\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["count"] == 3
+ assert parsed["filenames"] == ["a.txt", "b.txt", "c.txt"]
+
+
+def test_multipart_part_without_headers_separator(gw_event):
+ """Test that a malformed part missing the header/body separator is skipped."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {"filename": file_data.filename}
+
+ # Build a body with one malformed part (no \r\n\r\n) and one valid part
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f"This part has no header separator at all\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="good.txt"\r\n'
+ f"\r\n"
+ f"good content\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["filename"] == "good.txt"
+
+
+def test_multipart_part_without_field_name(gw_event):
+ """Test that a part missing the name parameter in Content-Disposition is skipped."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {"filename": file_data.filename}
+
+ # Build a body with one part that has no name= param and one valid part
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f"Content-Disposition: form-data\r\n"
+ f"\r\n"
+ f"orphan content\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="valid.txt"\r\n'
+ f"\r\n"
+ f"valid content\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["filename"] == "valid.txt"
+
+
+def test_upload_file_validate_error():
+ """Test UploadFile._validate raises ValueError for non-UploadFile values."""
+ from aws_lambda_powertools.event_handler.openapi.params import UploadFile
+
+ with pytest.raises(ValueError, match="Expected UploadFile, got str"):
+ UploadFile._validate("not an upload file")
+
+ with pytest.raises(ValueError, match="Expected UploadFile, got int"):
+ UploadFile._validate(42)
+
+
+def test_multipart_unclosed_quote_in_header():
+ """Test that _extract_header_param returns None when quote is unclosed."""
+ from aws_lambda_powertools.event_handler.middlewares.openapi_validation import _extract_header_param
+
+ # name=" is present but closing quote is missing
+ result = _extract_header_param('Content-Disposition: form-data; name="broken', "name")
+ assert result is None
+
+
+def test_multipart_generic_parse_error(gw_event):
+ """Test that non-ValueError exceptions during multipart parsing produce 422."""
+ from unittest.mock import patch
+
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {"filename": file_data.filename}
+
+ body_b64, content_type = _build_multipart_body(
+ [{"name": "file_data", "value": b"data", "filename": "test.txt"}],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body_b64
+ gw_event["isBase64Encoded"] = True
+
+ # Patch _parse_multipart_body to raise a non-ValueError (e.g. TypeError)
+ with patch(
+ "aws_lambda_powertools.event_handler.middlewares.openapi_validation._parse_multipart_body",
+ side_effect=TypeError("unexpected type"),
+ ):
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+ body = json.loads(result["body"])
+ assert body["detail"][0]["type"] == "multipart_invalid"
+
+
+# ---------- Cookie parameter tests ----------
+
+
+def test_cookie_param_basic(gw_event):
+ """Test basic cookie parameter extraction from REST API v1 (Cookie header)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "session_id=abc123; theme=dark"
+ # Clear multiValueHeaders to avoid interference
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "abc123"
+
+
+def test_cookie_param_missing_required(gw_event):
+ """Test that a missing required cookie returns 422."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "theme=dark"
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_cookie_param_with_default(gw_event):
+ """Test cookie parameter with a default value when cookie is absent."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(theme: Annotated[str, Cookie()] = "light"):
+ return {"theme": theme}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"].pop("cookie", None)
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["theme"] == "light"
+
+
+def test_cookie_param_multiple_cookies(gw_event):
+ """Test extracting multiple cookie parameters."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(
+ session_id: Annotated[str, Cookie()],
+ theme: Annotated[str, Cookie()] = "light",
+ ):
+ return {"session_id": session_id, "theme": theme}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "session_id=abc123; theme=dark"
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "abc123"
+ assert body["theme"] == "dark"
+
+
+def test_cookie_param_int_validation(gw_event):
+ """Test cookie parameter with int type validation."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(visits: Annotated[int, Cookie()]):
+ return {"visits": visits}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "visits=42"
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["visits"] == 42
+
+ # Invalid int
+ gw_event["headers"]["cookie"] = "visits=not_a_number"
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_cookie_param_http_api_v2(gw_event_http):
+ """Test cookie parameter with HTTP API v2 (dedicated cookies field)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_http["rawPath"] = "/me"
+ gw_event_http["requestContext"]["http"]["method"] = "GET"
+ gw_event_http["cookies"] = ["session_id=xyz789", "theme=dark"]
+
+ result = app(gw_event_http, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "xyz789"
+
+
+def test_cookie_param_lambda_function_url(gw_event_lambda_url):
+ """Test cookie parameter with Lambda Function URL (v2 format)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = LambdaFunctionUrlResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_lambda_url["rawPath"] = "/me"
+ gw_event_lambda_url["requestContext"]["http"]["method"] = "GET"
+ gw_event_lambda_url["cookies"] = ["session_id=fn_url_abc"]
+
+ result = app(gw_event_lambda_url, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "fn_url_abc"
+
+
+def test_cookie_param_alb(gw_event_alb):
+ """Test cookie parameter with ALB (Cookie header in multiValueHeaders)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = ALBResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_alb["path"] = "/me"
+ gw_event_alb["httpMethod"] = "GET"
+ gw_event_alb["multiValueHeaders"]["cookie"] = ["session_id=alb_abc"]
+
+ result = app(gw_event_alb, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "alb_abc"
+
+
+def test_cookie_param_openapi_schema():
+ """Test that Cookie() generates correct OpenAPI schema with in=cookie."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(
+ session_id: Annotated[str, Cookie(description="Session identifier")],
+ theme: Annotated[str, Cookie(description="UI theme")] = "light",
+ ):
+ return {"session_id": session_id}
+
+ schema = app.get_openapi_schema()
+ schema_dict = schema.model_dump(mode="json", by_alias=True, exclude_none=True)
+
+ path = schema_dict["paths"]["/me"]["get"]
+ params = path["parameters"]
+
+ cookie_params = [p for p in params if p["in"] == "cookie"]
+ assert len(cookie_params) == 2
+
+ session_param = next(p for p in cookie_params if p["name"] == "session_id")
+ assert session_param["required"] is True
+ assert session_param["description"] == "Session identifier"
+
+ theme_param = next(p for p in cookie_params if p["name"] == "theme")
+ assert theme_param.get("required") is not True
+ assert theme_param["description"] == "UI theme"
+
+
+def test_cookie_param_with_query_and_header(gw_event):
+ """Test that Cookie(), Query(), and Header() work together."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(
+ user_id: Annotated[str, Query()],
+ x_request_id: Annotated[str, Header()],
+ session_id: Annotated[str, Cookie()],
+ ):
+ return {
+ "user_id": user_id,
+ "x_request_id": x_request_id,
+ "session_id": session_id,
+ }
+
+ gw_event["path"] = "/me"
+ gw_event["queryStringParameters"] = {"user_id": "u123"}
+ gw_event["multiValueQueryStringParameters"] = {"user_id": ["u123"]}
+ gw_event["headers"]["x-request-id"] = "req-456"
+ gw_event["multiValueHeaders"] = {"x-request-id": ["req-456"], "cookie": ["session_id=sess-789"]}
+ gw_event["headers"]["cookie"] = "session_id=sess-789"
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["user_id"] == "u123"
+ assert body["x_request_id"] == "req-456"
+ assert body["session_id"] == "sess-789"
+
+
+def test_cookie_param_no_cookies_in_request(gw_event):
+ """Test that empty cookies dict is handled gracefully."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(theme: Annotated[str, Cookie()] = "light"):
+ return {"theme": theme}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"] = {}
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["theme"] == "light"
+
+
+def test_cookie_param_vpc_lattice_v2(gw_event_vpc_lattice):
+ """Test cookie parameter with VPC Lattice v2 (headers are lists)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = VPCLatticeV2Resolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_vpc_lattice["method"] = "GET"
+ gw_event_vpc_lattice["path"] = "/me"
+ gw_event_vpc_lattice["headers"]["cookie"] = ["session_id=lattice_abc"]
+
+ result = app(gw_event_vpc_lattice, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "lattice_abc"
+
+
+def test_cookie_param_vpc_lattice_v1(gw_event_vpc_lattice_v1):
+ """Test cookie parameter with VPC Lattice v1 (comma-separated headers)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = VPCLatticeResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_vpc_lattice_v1["method"] = "GET"
+ gw_event_vpc_lattice_v1["raw_path"] = "/me"
+ gw_event_vpc_lattice_v1["headers"]["cookie"] = "session_id=lattice_v1_abc"
+
+ result = app(gw_event_vpc_lattice_v1, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "lattice_v1_abc"
+
+
+def test_alb_response_none_body_with_validation(gw_event_alb):
+ # GIVEN an ALBResolver with validation enabled
+ app = ALBResolver(enable_validation=True)
+
+ gw_event_alb["path"] = "/no-content"
+ gw_event_alb["httpMethod"] = "DELETE"
+
+ # WHEN a handler returns Response with body=None and return type is None
+ @app.delete("/no-content")
+ def handler() -> None:
+ return Response(status_code=204, body=None)
+
+ # THEN the response should be 204 with empty body (not 422 validation error)
+ result = app(gw_event_alb, {})
+ assert result["statusCode"] == 204
+ assert result["body"] == ""
+
+
+def test_alb_response_typed_none_body_with_validation(gw_event_alb):
+ # GIVEN an ALBResolver with validation enabled
+ app = ALBResolver(enable_validation=True)
+
+ gw_event_alb["path"] = "/no-content"
+ gw_event_alb["httpMethod"] = "DELETE"
+
+ # WHEN a handler returns Response[None] with body=None
+ @app.delete("/no-content")
+ def handler() -> Response[None]:
+ return Response(status_code=204, body=None)
+
+ # THEN the response should be 204 with empty body (not 422 validation error)
+ result = app(gw_event_alb, {})
+ assert result["statusCode"] == 204
+ assert result["body"] == ""
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_with_pep563.py b/tests/functional/event_handler/_pydantic/test_openapi_with_pep563.py
new file mode 100644
index 00000000000..35ce00b8482
--- /dev/null
+++ b/tests/functional/event_handler/_pydantic/test_openapi_with_pep563.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated # noqa: TC002
+
+from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver
+from aws_lambda_powertools.event_handler.openapi.models import (
+ ParameterInType,
+ Schema,
+)
+from aws_lambda_powertools.event_handler.openapi.params import (
+ Body,
+ Query,
+)
+
+JSON_CONTENT_TYPE = "application/json"
+
+
+class Todo(BaseModel):
+ id: int = Field(examples=[1])
+ title: str = Field(examples=["Example 1"])
+ priority: float = Field(examples=[0.5])
+ completed: bool = Field(examples=[True])
+
+
+def test_openapi_with_pep563_and_input_model():
+ app = APIGatewayRestResolver()
+
+ @app.get("/users", summary="Get Users", operation_id="GetUsers", description="Get paginated users", tags=["Users"])
+ def handler(
+ count: Annotated[
+ int,
+ Query(gt=0, lt=100, examples=["Example 1"]),
+ ] = 1,
+ ):
+ print(count)
+ raise NotImplementedError()
+
+ schema = app.get_openapi_schema()
+
+ get = schema.paths["/users"].get
+ assert len(get.parameters) == 1
+ assert get.summary == "Get Users"
+ assert get.operationId == "GetUsers"
+ assert get.description == "Get paginated users"
+ assert get.tags == ["Users"]
+
+ parameter = get.parameters[0]
+ assert parameter.required is False
+ assert parameter.name == "count"
+ assert parameter.in_ == ParameterInType.query
+ assert parameter.schema_.type == "integer"
+ assert parameter.schema_.default == 1
+ assert parameter.schema_.title == "Count"
+ assert parameter.schema_.exclusiveMinimum == 0
+ assert parameter.schema_.exclusiveMaximum == 100
+ assert len(parameter.schema_.examples) == 1
+ assert parameter.schema_.examples[0] == "Example 1"
+
+
+def test_openapi_with_pep563_and_output_model():
+ app = APIGatewayRestResolver()
+
+ @app.get("/")
+ def handler() -> Todo:
+ return Todo(id=0, title="", priority=0.0, completed=False)
+
+ schema = app.get_openapi_schema()
+ assert "Todo" in schema.components.schemas
+ todo_schema = schema.components.schemas["Todo"]
+ assert isinstance(todo_schema, Schema)
+
+ assert "id" in todo_schema.properties
+ id_property = todo_schema.properties["id"]
+ assert id_property.examples == [1]
+
+ assert "title" in todo_schema.properties
+ title_property = todo_schema.properties["title"]
+ assert title_property.examples == ["Example 1"]
+
+ assert "priority" in todo_schema.properties
+ priority_property = todo_schema.properties["priority"]
+ assert priority_property.examples == [0.5]
+
+ assert "completed" in todo_schema.properties
+ completed_property = todo_schema.properties["completed"]
+ assert completed_property.examples == [True]
+
+
+def test_openapi_with_pep563_and_annotated_body():
+ app = APIGatewayRestResolver()
+
+ @app.post("/todo")
+ def create_todo(
+ todo_create_request: Annotated[Todo, Body(title="New Todo")],
+ ) -> dict:
+ return {"message": f"Created todo {todo_create_request.title}"}
+
+ schema = app.get_openapi_schema()
+ assert "Todo" in schema.components.schemas
+ todo_schema = schema.components.schemas["Todo"]
+ assert isinstance(todo_schema, Schema)
+
+ assert "id" in todo_schema.properties
+ id_property = todo_schema.properties["id"]
+ assert id_property.examples == [1]
+
+ assert "title" in todo_schema.properties
+ title_property = todo_schema.properties["title"]
+ assert title_property.examples == ["Example 1"]
+
+ assert "priority" in todo_schema.properties
+ priority_property = todo_schema.properties["priority"]
+ assert priority_property.examples == [0.5]
+
+ assert "completed" in todo_schema.properties
+ completed_property = todo_schema.properties["completed"]
+ assert completed_property.examples == [True]
diff --git a/tests/functional/event_handler/_pydantic/test_per_route_validation.py b/tests/functional/event_handler/_pydantic/test_per_route_validation.py
new file mode 100644
index 00000000000..f6742b960ee
--- /dev/null
+++ b/tests/functional/event_handler/_pydantic/test_per_route_validation.py
@@ -0,0 +1,301 @@
+from typing import cast
+
+from pydantic import BaseModel
+
+from aws_lambda_powertools.event_handler import APIGatewayRestResolver
+from tests.functional.utils import load_event
+
+
+class TodoItem(BaseModel):
+ name: str
+ completed: bool = False
+
+
+def test_per_route_validation_enabled_on_single_route():
+ # GIVEN APIGatewayRestResolver with global enable_validation
+ # AND one route with explicit enable_validation=True
+ # AND one route without explicit validation (inherits global)
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/explicitly-validated", enable_validation=True)
+ def explicitly_validated_route() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ @app.get("/inherit-validated")
+ def inherit_validated_route() -> TodoItem:
+ return TodoItem(name="inherit", completed=False)
+
+ # WHEN calling the explicitly validated route
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/explicitly-validated"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN response should be validated and successful
+ assert result["statusCode"] == 200
+ assert '"name":"test"' in result["body"]
+
+ # WHEN calling the route that inherits validation
+ event["path"] = "/inherit-validated"
+ result = app(event, {})
+
+ # THEN response should also be validated
+ assert result["statusCode"] == 200
+ assert "inherit" in result["body"]
+
+
+def test_per_route_validation_disabled_on_single_route():
+ # GIVEN APIGatewayRestResolver with global enable_validation=True
+ # AND one route with enable_validation=False
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/validated")
+ def validated_route() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ @app.get("/not-validated", enable_validation=False)
+ def not_validated_route() -> dict:
+ # This returns invalid data that doesn't match TodoItem but should not fail
+ return {"invalid": "data", "extra": "field"}
+
+ # WHEN calling the validated route
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/validated"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN response should be validated and successful
+ assert result["statusCode"] == 200
+ assert '"name":"test"' in result["body"]
+
+ # WHEN calling the non-validated route with invalid response
+ event["path"] = "/not-validated"
+ result = app(event, {})
+
+ # THEN response should bypass validation
+ assert result["statusCode"] == 200
+ assert "invalid" in result["body"]
+
+
+def test_per_route_validation_request_body_validation():
+ # GIVEN APIGatewayRestResolver WITH global validation enabled
+ # AND routes with different validation settings
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/create")
+ def create_item(item: TodoItem) -> TodoItem:
+ return item
+
+ @app.post("/create-no-validation", enable_validation=False)
+ def create_item_no_validation() -> dict:
+ # Without validation, we manually parse the body
+ body = app.current_event.json_body
+ return body
+
+ # WHEN calling validated route with valid body
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/create"
+ event["httpMethod"] = "POST"
+ event["body"] = '{"name": "New Task", "completed": false}'
+
+ result = app(event, {})
+
+ # THEN request should be validated and successful
+ assert result["statusCode"] == 200
+ assert "New Task" in result["body"]
+
+ # WHEN calling validated route with invalid body
+ event["body"] = '{"invalid": "data"}'
+ result = app(event, {})
+
+ # THEN validation should fail with 422
+ assert result["statusCode"] == 422
+
+ # WHEN calling non-validated route with any body
+ event["path"] = "/create-no-validation"
+ event["body"] = '{"invalid": "data"}'
+ result = app(event, {})
+
+ # THEN should succeed without validation
+ assert result["statusCode"] == 200
+
+
+def test_per_route_validation_inherits_from_resolver():
+ # GIVEN APIGatewayRestResolver with global enable_validation=True
+ # AND routes without explicit enable_validation setting
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/route1")
+ def route1() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ @app.post("/route2")
+ def route2(item: TodoItem) -> TodoItem:
+ return item
+
+ # WHEN calling routes without explicit validation setting
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/route1"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN they should inherit global validation setting
+ assert result["statusCode"] == 200
+
+ # WHEN calling POST with invalid body
+ event["path"] = "/route2"
+ event["httpMethod"] = "POST"
+ event["body"] = '{"invalid": "data"}'
+
+ result = app(event, {})
+
+ # THEN validation should be applied (422 error)
+ assert result["statusCode"] == 422
+
+
+def test_per_route_validation_mixed_routes():
+ # GIVEN APIGatewayRestResolver with mixed validation settings
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/always-validated")
+ def always_validated() -> TodoItem:
+ return TodoItem(name="validated", completed=True)
+
+ @app.get("/never-validated", enable_validation=False)
+ def never_validated():
+ # Return invalid TodoItem structure
+ return {"wrong": "structure"}
+
+ @app.get("/inherit-global")
+ def inherit_global() -> TodoItem:
+ return TodoItem(name="inherit", completed=False)
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+
+ # WHEN calling route with global validation (enable_validation not set)
+ event["path"] = "/inherit-global"
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert "inherit" in result["body"]
+
+ # WHEN calling route with explicit validation=False returning invalid data
+ event["path"] = "/never-validated"
+ result = app(event, {})
+ # THEN should succeed without validation
+ assert result["statusCode"] == 200
+ assert "wrong" in result["body"]
+
+ # WHEN calling route with inherited validation
+ event["path"] = "/always-validated"
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert "validated" in result["body"]
+
+
+def test_per_route_validation_with_resolver_disabled():
+ # GIVEN APIGatewayRestResolver with global validation disabled (default)
+ # Note: Per-route enable_validation=True requires the resolver to have
+ # enable_validation=True for the middleware to exist. This test documents
+ # that you can't opt-in to validation per-route without global validation.
+ app = APIGatewayRestResolver() # enable_validation=False by default
+
+ @app.get("/no-explicit-setting")
+ def default_route() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+
+ # WHEN calling route without explicit setting (inherits False)
+ event["path"] = "/no-explicit-setting"
+ result = app(event, {})
+
+ # THEN should not be validated (returns as-is)
+ assert result["statusCode"] == 200
+ assert "test" in result["body"]
+
+
+def test_per_route_validation_response_error_code():
+ # GIVEN APIGatewayRestResolver with custom response_validation_error_http_code
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/invalid-response")
+ def invalid_response() -> TodoItem:
+ # Return dict that doesn't match TodoItem model to test validation error handling
+ return cast(TodoItem, {"bad": "response"})
+
+ # WHEN calling route that returns invalid response
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/invalid-response"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN should return 422 Unprocessable Entity (default response validation error code)
+ assert result["statusCode"] == 422
+
+
+def test_per_route_validation_with_pydantic_v2():
+ """Test that per-route validation actually validates when resolver has validation disabled"""
+ # GIVEN APIGatewayRestResolver WITHOUT global validation
+ app = APIGatewayRestResolver()
+
+ class Task(BaseModel):
+ title: str
+ priority: int
+
+ @app.get("/task", enable_validation=True)
+ def get_task() -> Task:
+ # Return invalid data — missing 'title' and 'priority'
+ return cast(Task, {"wrong": "data"})
+
+ @app.get("/unvalidated-task")
+ def get_unvalidated_task():
+ return {"title": "Anything", "extra": "field"}
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+
+ # WHEN calling validated route with invalid data
+ event["path"] = "/task"
+ result = app(event, {})
+
+ # THEN validation must reject it with 422
+ assert result["statusCode"] == 422
+
+ # WHEN calling unvalidated route
+ event["path"] = "/unvalidated-task"
+ result = app(event, {})
+
+ # THEN should return as-is without validation
+ assert result["statusCode"] == 200
+ assert "extra" in result["body"]
+
+
+def test_per_route_opt_in_validation_with_valid_data():
+ """Test that per-route opt-in validation passes valid data and serializes correctly"""
+ # GIVEN APIGatewayRestResolver WITHOUT global validation
+ app = APIGatewayRestResolver()
+
+ class Task(BaseModel):
+ title: str
+ priority: int
+
+ @app.get("/task", enable_validation=True)
+ def get_task() -> Task:
+ return Task(title="Important", priority=1)
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+ event["path"] = "/task"
+
+ # WHEN calling validated route with valid data
+ result = app(event, {})
+
+ # THEN validation passes and response is serialized
+ assert result["statusCode"] == 200
+ assert "Important" in result["body"]
diff --git a/tests/functional/event_handler/_pydantic/test_resolve_async_validation.py b/tests/functional/event_handler/_pydantic/test_resolve_async_validation.py
new file mode 100644
index 00000000000..92b414f72b5
--- /dev/null
+++ b/tests/functional/event_handler/_pydantic/test_resolve_async_validation.py
@@ -0,0 +1,55 @@
+import asyncio
+
+from aws_lambda_powertools.event_handler.api_gateway import (
+ APIGatewayHttpResolver,
+ BaseRouter,
+)
+from tests.functional.utils import load_event
+
+API_RESTV2_EVENT = load_event("apiGatewayProxyV2Event_GET.json")
+
+
+def _setup_app(app, event):
+ BaseRouter.current_event = app._to_proxy_event(event)
+ BaseRouter.lambda_context = {}
+
+
+class TestResolveAsyncValidation:
+ def test_validation_middleware_created_and_used(self):
+ # GIVEN a resolver with validation enabled and an async handler
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ @app.get("/my/path")
+ async def get_lambda() -> dict:
+ await asyncio.sleep(0)
+ return {"message": "validated"}
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_RESTV2_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the validation middlewares are created and the response is valid
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert hasattr(app, "_request_validation_middleware")
+ assert hasattr(app, "_response_validation_middleware")
+
+ def test_validation_middleware_lazy_created_for_per_route_validation(self):
+ # GIVEN a resolver WITHOUT global validation, but a route WITH enable_validation=True
+ app = APIGatewayHttpResolver()
+ assert not hasattr(app, "_request_validation_middleware")
+
+ @app.get("/my/path", enable_validation=True)
+ async def get_lambda() -> dict:
+ await asyncio.sleep(0)
+ return {"message": "lazy validated"}
+
+ # WHEN calling _resolve_async (triggers lazy creation in Route.call_async)
+ _setup_app(app, API_RESTV2_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN validation middlewares are lazily created on the app
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert hasattr(app, "_request_validation_middleware")
+ assert hasattr(app, "_response_validation_middleware")
diff --git a/tests/functional/event_handler/required_dependencies/__init__.py b/tests/functional/event_handler/required_dependencies/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/functional/event_handler/required_dependencies/appsync/__init__.py b/tests/functional/event_handler/required_dependencies/appsync/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py
new file mode 100644
index 00000000000..2466ac6d6a3
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py
@@ -0,0 +1,1107 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from aws_lambda_powertools.event_handler import AppSyncResolver
+from aws_lambda_powertools.event_handler.graphql_appsync.exceptions import InvalidBatchResponse, ResolverNotFoundError
+from aws_lambda_powertools.event_handler.graphql_appsync.router import Router
+from aws_lambda_powertools.utilities.typing import LambdaContext
+from aws_lambda_powertools.warnings import PowertoolsUserWarning
+from tests.functional.utils import load_event
+
+if TYPE_CHECKING:
+ from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
+
+
+# TESTS RECEIVING THE EVENT PARTIALLY AND PROCESS EACH RECORD PER TIME.
+def test_resolve_batch_processing_with_related_events_one_at_time():
+ # GIVEN An event with multiple requests to fetch related posts for different post IDs.
+ event = [
+ {
+ "arguments": {},
+ "identity": "None",
+ "source": {
+ "post_id": "3",
+ "title": "Third book",
+ },
+ "info": {
+ "selectionSetList": [
+ "title",
+ ],
+ "selectionSetGraphQL": "{\n title\n}",
+ "fieldName": "relatedPosts",
+ "parentTypeName": "Post",
+ },
+ },
+ {
+ "arguments": {},
+ "identity": "None",
+ "source": {
+ "post_id": "4",
+ "title": "Fifth book",
+ },
+ "info": {
+ "selectionSetList": [
+ "title",
+ ],
+ "selectionSetGraphQL": "{\n title\n}",
+ "fieldName": "relatedPosts",
+ "parentTypeName": "Post",
+ },
+ },
+ {
+ "arguments": {},
+ "identity": "None",
+ "source": {
+ "post_id": "1",
+ "title": "First book",
+ },
+ "info": {
+ "selectionSetList": [
+ "title",
+ ],
+ "selectionSetGraphQL": "{\n title\n}",
+ "fieldName": "relatedPosts",
+ "parentTypeName": "Post",
+ },
+ },
+ ]
+
+ # GIVEN A dictionary of posts and a dictionary of related posts.
+ posts = {
+ "1": {
+ "post_id": "1",
+ "title": "First book",
+ },
+ "2": {
+ "post_id": "2",
+ "title": "Second book",
+ },
+ "3": {
+ "post_id": "3",
+ "title": "Third book",
+ },
+ "4": {
+ "post_id": "4",
+ "title": "Fourth book",
+ },
+ }
+
+ posts_related = {
+ "1": [posts["2"]],
+ "2": [posts["3"], posts["4"], posts["1"]],
+ "3": [posts["2"], posts["1"]],
+ "4": [posts["3"], posts["1"]],
+ }
+
+ app = AppSyncResolver()
+
+ @app.batch_resolver(type_name="Post", field_name="relatedPosts", aggregate=False)
+ def related_posts(event: AppSyncResolverEvent) -> list | None:
+ return posts_related[event.source["post_id"]]
+
+ # WHEN related_posts function, which is the batch resolver, is called with the event.
+ result = app.resolve(event, LambdaContext())
+
+ # THEN the result must be a list of related posts
+ assert result == [
+ posts_related["3"],
+ posts_related["4"],
+ posts_related["1"],
+ ]
+
+
+# Batch resolver tests
+def test_resolve_batch_processing_with_simple_queries_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the batch resolver for the listLocations field is defined
+ @app.batch_resolver(field_name="listLocations", aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ return event.source["id"] if event.source else None
+
+ # THEN the resolver should correctly process the batch of queries
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+ assert not app.current_event
+
+
+def test_resolve_batch_processing_with_raise_on_exception_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=True
+ @app.batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ # THEN the resolver should raise a RuntimeError when processing the batch of queries
+ with pytest.raises(RuntimeError):
+ app.resolve(event, LambdaContext())
+
+
+def test_async_resolve_batch_processing_with_raise_on_exception_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the 'listLocations' field is defined with raise_on_error=True
+ @app.async_batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
+ async def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ # THEN the resolver should raise a RuntimeError when processing the batch of queries
+ with pytest.raises(RuntimeError):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_batch_processing_without_exception_one_at_time():
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ @app.batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ # Call the implicit handler
+ result = app.resolve(event, LambdaContext())
+ assert result == [None, None, None]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+ assert not app.current_event
+
+
+def test_resolve_async_batch_processing_without_exception_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the batch resolver for the 'listLocations' field is defined with raise_on_error=False
+ @app.async_batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
+ async def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ result = app.resolve(event, LambdaContext())
+
+ # THEN the resolver should return None for each event in the batch
+ assert len(app.current_batch_event) == len(event)
+ assert result == [None, None, None]
+
+
+def test_resolver_batch_with_resolver_not_found_one_at_time():
+ # GIVEN a AppSyncResolver
+ app = AppSyncResolver()
+ router = Router()
+
+ # WHEN we have an event
+ # WHEN the event field_name doesn't match with the resolver field_name
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listCars",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listCars",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ @router.batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ app.include_router(router)
+
+ # THEN must fail with ResolverNotFoundError
+ with pytest.raises(ResolverNotFoundError, match="No resolver found for.*"):
+ app.resolve(mock_event1, LambdaContext())
+
+
+def test_resolver_batch_with_sync_and_async_resolver_at_same_time():
+ # GIVEN a AppSyncResolver
+ app = AppSyncResolver()
+ router = Router()
+
+ # WHEN we have an event
+ # WHEN the event field_name doesn't match with the resolver field_name
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listCars",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listCars",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ @router.batch_resolver(type_name="Query", field_name="listCars", aggregate=False)
+ def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @router.async_batch_resolver(type_name="Query", field_name="listCars", aggregate=False)
+ async def get_locations_async(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ app.include_router(router)
+
+ # THEN must raise a PowertoolsUserWarning
+ with pytest.warns(PowertoolsUserWarning, match="Both synchronous and asynchronous resolvers*"):
+ app.resolve(mock_event1, LambdaContext())
+
+
+def test_batch_resolver_with_router():
+ # GIVEN an AppSyncResolver and a Router instance
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @router.batch_resolver(field_name="listLocations2", aggregate=False)
+ def get_locations2(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations2#{name}#" + event.source["id"]
+
+ # WHEN we include the routes
+ app.include_router(router)
+
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listLocations",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+ mock_event2 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations2",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations2",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "2",
+ },
+ },
+ ]
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN the resolvers should return the expected results
+ assert result1 == ["get_locations#value#1"]
+ assert result2 == ["get_locations2#value#2"]
+
+
+def test_resolve_async_batch_processing():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the 'listLocations' field is defined
+ @app.async_batch_resolver(field_name="listLocations", aggregate=False)
+ async def create_something(event: AppSyncResolverEvent) -> list | None:
+ return event.source["id"] if event.source else None
+
+ # THEN the resolver should correctly process the batch of queries asynchronously
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+
+
+def test_resolve_async_batch_and_sync_singular_processing():
+ # GIVEN a router with an async batch resolver for 'listLocations' and a sync singular resolver for 'listLocation'
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.async_batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ async def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @app.resolver(type_name="Query", field_name="listLocation")
+ def get_location(name: str) -> str:
+ return f"get_location#{name}"
+
+ app.include_router(router)
+
+ # WHEN resolving a batch of events for async 'listLocations' and a singular event for 'listLocation'
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listLocations",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+ mock_event2 = {"typeName": "Query", "fieldName": "listLocation", "arguments": {"name": "value"}}
+
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN the resolvers should return the expected results
+ assert result1 == ["get_locations#value#1"]
+ assert result2 == "get_location#value"
+
+
+def test_async_resolver_include_batch_resolver():
+ # GIVEN an AppSyncResolver instance and a Router
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.async_batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ async def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @app.async_batch_resolver(field_name="listLocations2", aggregate=False)
+ async def get_locations2(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations2#{name}#" + event.source["id"]
+
+ app.include_router(router)
+
+ # WHEN two different events needs to be resolved
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listLocations",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+ mock_event2 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations2",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations2",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "2",
+ },
+ },
+ ]
+
+ # WHEN Resolve the events using the AppSyncResolver
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN Verify that the results match the expected values
+ assert result1 == ["get_locations#value#1"]
+ assert result2 == ["get_locations2#value#2"]
+
+
+def test_resolve_batch_processing_with_simple_queries_with_aggregate():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function returns a List
+ @app.batch_resolver(field_name="listLocations")
+ def create_something(event: list[AppSyncResolverEvent]) -> list: # noqa AA03 VNE003
+ results = []
+ for record in event:
+ results.append(record.source.get("id") if record.source else None)
+
+ return results
+
+ # THEN the resolver should correctly process the batch of queries
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+
+
+def test_resolve_async_batch_processing_with_simple_queries_with_aggregate():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function returns a List
+ @app.async_batch_resolver(field_name="listLocations")
+ async def create_something(event: list[AppSyncResolverEvent]) -> list: # noqa AA03 VNE003
+ results = []
+ for record in event:
+ results.append(record.source.get("id") if record.source else None)
+
+ return results
+
+ # THEN the resolver should correctly process the batch of queries
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+
+
+def test_resolve_batch_processing_with_aggregate_and_returning_a_non_list():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function return something different than a List
+ @app.batch_resolver(field_name="listLocations")
+ def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ return event[0].source.get("id") if event[0].source else None
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_async_batch_processing_with_aggregate_and_returning_a_non_list():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function return something different than a List
+ @app.async_batch_resolver(field_name="listLocations")
+ async def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ return event[0].source.get("id") if event[0].source else None
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_sync_batch_processing_with_aggregate_and_without_return():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function there is no return statement
+ @app.batch_resolver(field_name="listLocations")
+ def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ def do_something_with_post_id(post_id): ...
+
+ post_id = event[0].source.get("id") if event[0].source else None
+ do_something_with_post_id(post_id)
+
+ # No Return statement
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_async_batch_processing_with_aggregate_and_without_return():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function there is no return statement
+ @app.async_batch_resolver(field_name="listLocations")
+ async def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ def do_something_with_post_id(post_id): ...
+
+ post_id = event[0].source.get("id") if event[0].source else None
+ do_something_with_post_id(post_id)
+
+ # No Return statement
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_include_router_access_batch_current_event():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver, a Router instance, and a resolver function registered with the router
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.batch_resolver(field_name="createSomething")
+ def get_user(event: list) -> list:
+ return [router.current_batch_event[0].identity.sub]
+
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert ret[0] == mock_event[0]["identity"]["sub"]
+
+
+def test_app_access_batch_current_event():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.batch_resolver(field_name="createSomething")
+ def get_user(event: list) -> list:
+ return [app.current_batch_event[0].identity.sub]
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert ret[0] == mock_event[0]["identity"]["sub"]
+
+
+def test_context_is_accessible_in_sync_batch_resolver():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.batch_resolver(field_name="createSomething")
+ def get_user(event: list) -> list:
+ return [app.context.get("project_name")]
+
+ # WHEN we resolve the event
+ app.append_context(project_name="powertools")
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert app.context == {}
+ assert ret[0] == "powertools"
+
+
+def test_context_is_accessible_in_async_batch_resolver():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.async_batch_resolver(field_name="createSomething")
+ async def get_user(event: list) -> list:
+ return [app.context.get("project_name")]
+
+ # WHEN we resolve the event
+ app.append_context(project_name="powertools")
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert app.context == {}
+ assert ret[0] == "powertools"
+
+
+def test_exception_handler_with_batch_resolver_and_raise_exception():
+ # GIVEN a AppSyncResolver instance
+ app = AppSyncResolver()
+
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ # WHEN we configure exception handler for ValueError
+ @app.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ return {"message": "error"}
+
+ # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=True
+ @app.batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise ValueError
+
+ # Call the implicit handler
+ result = app(event, {})
+
+ # THEN the return must be the Exception Handler error message
+ assert result["message"] == "error"
+
+
+def test_exception_handler_with_batch_resolver_and_no_raise_exception():
+ # GIVEN a AppSyncResolver instance
+ app = AppSyncResolver()
+
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ # WHEN we configure exception handler for ValueError
+ @app.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ return {"message": "error"}
+
+ # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=False
+ @app.batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise ValueError
+
+ # Call the implicit handler
+ result = app(event, {})
+
+ # THEN the return must not trigger the Exception Handler, but instead return from the resolver
+ assert result == [None, None, None]
diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py
new file mode 100644
index 00000000000..4d53c3cb934
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py
@@ -0,0 +1,1614 @@
+import asyncio
+from copy import deepcopy
+
+import pytest
+
+from aws_lambda_powertools.event_handler import AppSyncEventsResolver
+from aws_lambda_powertools.event_handler.events_appsync.exceptions import UnauthorizedException
+from aws_lambda_powertools.event_handler.events_appsync.router import Router
+from aws_lambda_powertools.warnings import PowertoolsUserWarning
+from tests.functional.utils import load_event
+
+
+class LambdaContext:
+ def __init__(self):
+ self.function_name = "test-func"
+ self.memory_limit_in_mb = 128
+ self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
+ self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
+
+ def get_remaining_time_in_millis(self) -> int:
+ return 1000
+
+
+@pytest.fixture(scope="module")
+def lambda_context() -> LambdaContext:
+ """Create a new LambdaContext instance for each test module."""
+ return LambdaContext()
+
+
+@pytest.fixture(scope="module")
+def mock_event():
+ """Load a sample AppSyncEventsEvent for each test module."""
+ return load_event("appSyncEventsEvent.json")
+
+
+def test_publish_event_with_synchronous_resolver(lambda_context, mock_event):
+ """Test handling a publish event with a synchronous resolver."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a synchronous resolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"processed": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the correct response
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_with_async_resolver(lambda_context, mock_event):
+ """Test handling a publish event with an asynchronous resolver."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an asynchronous resolver
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def test_handler(payload):
+ await asyncio.sleep(0.01) # Simulate async work
+ return {"processed": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the correct response
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["data"] == "test data"
+
+
+def test_publish_event_with_error_handling(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver that raises an exception
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ raise ValueError("Test error")
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert "events" in result
+ assert "error" in result["events"][0]
+ assert "ValueError - Test error" in result["events"][0]["error"]
+ assert result["events"][0]["id"] == "123"
+
+
+def test_publish_event_with_router_inclusion(lambda_context, mock_event):
+ """Test including a router in the AppSyncEventsResolver."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data", "from_router": True}},
+ ]
+
+ # GIVEN a router with a resolver
+ router = Router()
+
+ @router.on_publish(path="/chat/*")
+ def router_handler(payload):
+ return {"from_router": True, "data": payload["data"]}
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the response from the router's handler
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"from_router": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_with_custom_context(lambda_context, mock_event):
+ """Test resolving events with custom context data."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with custom context
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ # Access the context within the handler
+ return {
+ "processed": True,
+ "data": payload["data"],
+ "user_id": app.context.get("user_id"),
+ "role": app.context.get("role"),
+ }
+
+ # WHEN we resolve the event
+ app.append_context(user_id="test-user", role="admin")
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the response with context data
+ expected_result = {
+ "events": [
+ {
+ "id": "123",
+ "payload": {
+ "processed": True,
+ "data": "test data",
+ "user_id": "test-user",
+ "role": "admin",
+ },
+ },
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_with_aggregate_mode(lambda_context, mock_event):
+ """Test handling a publish event with aggregate mode enabled."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data 1"}},
+ {"id": "456", "payload": {"data": "test data 2"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an aggregate resolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def test_batch_handler(payload):
+ # Process all events at once
+ return [{"batch_processed": True, "data": item["payload"]["data"]} for item in payload]
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the batch processed response
+ expected_result = {
+ "events": [
+ {"batch_processed": True, "data": "test data 1"},
+ {"batch_processed": True, "data": "test data 2"},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_async_publish_event_with_aggregate_mode(lambda_context, mock_event):
+ """Test handling an async publish event with aggregate mode enabled."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data 1"}},
+ {"id": "456", "payload": {"data": "test data 2"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async aggregate resolver
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*", aggregate=True)
+ async def test_async_batch_handler(payload):
+ # Simulate async processing of all events
+ await asyncio.sleep(0.01)
+ return [{"async_batch_processed": True, "data": item["payload"]["data"]} for item in payload]
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the batch processed response
+ expected_result = {
+ "events": [
+ {"async_batch_processed": True, "data": "test data 1"},
+ {"async_batch_processed": True, "data": "test data 2"},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_no_matching_resolver(lambda_context, mock_event):
+ """Test handling a publish event when no matching resolver is found."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/unknown/path"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with no matching resolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"processed": True}
+
+ # WHEN we resolve the event with a warning
+ with pytest.warns(PowertoolsUserWarning, match="No resolvers were found for publish operations"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the original payload returned as is
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_multiple_resolvers_for_same_path(lambda_context, mock_event):
+ """Test behavior when both sync and async resolvers exist for the same path."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with both sync and async resolvers for the same path
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def sync_handler(payload):
+ return {"sync_processed": True, "data": payload["data"]}
+
+ @app.async_on_publish(path="/default/*")
+ async def async_handler(event):
+ await asyncio.sleep(0.01)
+ return {"async_processed": True, "data": event["data"]}
+
+ # WHEN we resolve the event, with a warning expected
+ with pytest.warns(PowertoolsUserWarning, match="Both synchronous and asynchronous resolvers found"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the sync resolver should be used (takes precedence)
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_custom_exception_handling(lambda_context, mock_event):
+ """Test handling custom exceptions during event processing."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN a custom exception class
+ class NotAuthorized(Exception):
+ pass
+
+ # GIVEN an AppSyncEventsResolver with a resolver that raises a custom exception
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ if payload["data"] == "test data":
+ raise NotAuthorized("Not authorized")
+ return {"processed": True}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response with our custom exception
+ assert "events" in result
+ assert "error" in result["events"][0]
+ assert "NotAuthorized - Not authorized" in result["events"][0]["error"]
+ assert result["events"][0]["id"] == "123"
+
+
+def test_async_resolver_with_error_handling(lambda_context, mock_event):
+ """Test error handling with async resolvers during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async resolver that raises an exception
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def test_handler(payload):
+ await asyncio.sleep(0.01) # Simulate async work
+ raise ValueError("Async test error")
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert "error" in result["events"][0]
+ assert "ValueError - Async test error" in result["events"][0]["error"]
+
+
+def test_lambda_handler_with_call_method(lambda_context, mock_event):
+ """Test that the lambda handler function properly processes events."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver setup
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"lambda_processed": True, "data": payload["data"]}
+
+ # WHEN we use the AppSyncEventsResolver as a Lambda handler
+ result = app(mock_event, lambda_context) # Using __call__ method which calls resolve()
+
+ # THEN we should get the processed response
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"lambda_processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_event_with_mixed_success_and_errors(lambda_context, mock_event):
+ """Test handling a batch of events with mixed success and failure outcomes."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "good data"}},
+ {"id": "456", "payload": {"data": "bad data"}},
+ {"id": "789", "payload": {"data": "good data again"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver that conditionally fails
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ if payload["data"] == "bad data":
+ raise ValueError("Bad data detected")
+ return {"success": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get mixed results with success and error responses
+ assert "events" in result
+ assert len(result["events"]) == 3
+
+ # First event should be successful
+ assert "payload" in result["events"][0]
+ assert result["events"][0]["payload"]["success"] is True
+ assert result["events"][0]["payload"]["data"] == "good data"
+
+ # Second event should have an error
+ assert "error" in result["events"][1]
+ assert "ValueError - Bad data detected" in result["events"][1]["error"]
+
+ # Third event should be successful
+ assert "payload" in result["events"][2]
+ assert result["events"][2]["payload"]["success"] is True
+ assert result["events"][2]["payload"]["data"] == "good data again"
+
+
+def test_router_with_context_sharing(lambda_context, mock_event):
+ """Test that context is properly shared between routers and the main resolver."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/chat/message"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with context
+ router = Router()
+ router.append_context(service="chat")
+
+ @router.on_publish(path="/chat/*")
+ def router_handler(payload):
+ # Access shared context
+ return {
+ "from_router": True,
+ "service": router.context.get("service"),
+ "tenant": router.context.get("tenant"),
+ }
+
+ # GIVEN an AppSyncEventsResolver with its own context
+ app = AppSyncEventsResolver()
+ app.append_context(tenant="acme")
+
+ # Include the router and merge contexts
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the handler should have access to merged context from both sources
+ expected_result = {
+ "events": [
+ {
+ "id": "123",
+ "payload": {
+ "from_router": True,
+ "service": "chat",
+ "tenant": "acme",
+ },
+ },
+ ],
+ }
+ assert result == expected_result
+
+
+def test_context_cleared_after_resolution(lambda_context, mock_event):
+ """Test that context is properly cleared after event resolution."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with context data
+ app = AppSyncEventsResolver()
+ app.append_context(request_id="12345")
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ # Verify context exists during handler execution
+ assert app.context.get("request_id") == "12345"
+ return {"processed": True}
+
+ # WHEN we resolve the event
+ app.resolve(mock_event, lambda_context)
+
+ # THEN the context should be cleared afterward
+ assert app.context == {}
+
+
+def test_path_matching_mechanism(mocker, lambda_context, mock_event):
+ """Test the path matching mechanism for resolvers."""
+
+ mock_find_resolver = mocker.patch(
+ "aws_lambda_powertools.event_handler.events_appsync._registry.ResolverEventsRegistry.find_resolver",
+ )
+ # GIVEN a resolver that should be found
+ mock_resolver = {
+ "func": lambda payload: {"matched": True},
+ "aggregate": False,
+ }
+ mock_find_resolver.return_value = mock_resolver
+
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/chat/room/123/message"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ # WHEN we resolve the event
+ app.resolve(mock_event, lambda_context)
+
+ # THEN the registry should be queried with the correct path
+ mock_find_resolver.assert_called_with("/chat/room/123/message")
+
+
+def test_async_aggregate_with_parallel_processing(lambda_context, mock_event):
+ """Test that async aggregate handlers can process events in parallel."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["info"]["channel"]["path"] = "/default/process"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "item 1", "delay": 0.03}},
+ {"id": "456", "payload": {"sync_processed": True, "data": "item 2", "delay": 0.02}},
+ {"id": "789", "payload": {"sync_processed": True, "data": "item 3", "delay": 0.01}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async aggregate handler
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*", aggregate=True)
+ async def test_async_handler(payload):
+ # Create tasks for each event with different delays
+ tasks = []
+ for idx_event in payload:
+ tasks.append(process_single_event(idx_event["payload"]))
+
+ # Process all events in parallel
+ results = await asyncio.gather(*tasks)
+ return results
+
+ async def process_single_event(payload):
+ # Simulate variable processing time
+ await asyncio.sleep(payload["delay"])
+ return {"processed": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN all events should be processed
+ assert "events" in result
+ assert len(result["events"]) == 3
+
+ # Check all items were processed
+ processed_data = [item["data"] for item in result["events"]]
+ assert "item 1" in processed_data
+ assert "item 2" in processed_data
+ assert "item 3" in processed_data
+
+
+def test_both_app_and_router_for_same_path(lambda_context, mock_event):
+ """Test precedence when both app and router have resolvers for the same path."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/duplicate"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with a resolver
+ router = Router()
+
+ @router.on_publish(path="/default/duplicate")
+ def router_handler(payload):
+ return {"source": "router"}
+
+ # GIVEN an AppSyncEventsResolver with a resolver for the same path
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/duplicate")
+ def app_handler(payload):
+ return {"source": "app"}
+
+ # Include the router after defining the app handler
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the router's handler should take precedence as it was registered last
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"source": "router"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_event_with_real_world_example(lambda_context, mock_event):
+ """Test handling a more complex, real-world-like example."""
+ # GIVEN a more realistic publish event with multiple items
+ mock_event["info"]["channel"]["path"] = "/chat/messages"
+ mock_event["events"] = [
+ {
+ "id": "message-123",
+ "payload": {
+ "type": "text",
+ "content": "Hello, world!",
+ "timestamp": 1636718400000,
+ "sender": "user1",
+ },
+ },
+ {
+ "id": "message-456",
+ "payload": {
+ "type": "image",
+ "content": "https://example.com/image.jpg",
+ "timestamp": 1636718500000,
+ "sender": "user2",
+ },
+ },
+ ]
+
+ # GIVEN a router for chat-related operations
+ chat_router = Router()
+
+ @chat_router.on_publish(path="/chat/*")
+ def process_message(payload):
+ # Process message based on type
+ if payload["type"] == "text":
+ return {
+ "processed": True,
+ "messageType": "text",
+ "displayContent": payload["content"],
+ "timestamp": payload["timestamp"],
+ "sender": payload["sender"],
+ }
+ elif payload["type"] == "image":
+ return {
+ "processed": True,
+ "messageType": "image",
+ "displayContent": f"[Image] {payload['content']}",
+ "timestamp": payload["timestamp"],
+ "sender": payload["sender"],
+ }
+ else:
+ return {
+ "processed": False,
+ "error": "Unsupported message type",
+ }
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(chat_router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get properly processed messages
+ assert "events" in result
+ assert len(result["events"]) == 2
+
+ # Check text message
+ assert result["events"][0]["id"] == "message-123"
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["messageType"] == "text"
+ assert result["events"][0]["payload"]["displayContent"] == "Hello, world!"
+
+ # Check image message
+ assert result["events"][1]["id"] == "message-456"
+ assert result["events"][1]["payload"]["processed"] is True
+ assert result["events"][1]["payload"]["messageType"] == "image"
+ assert result["events"][1]["payload"]["displayContent"] == "[Image] https://example.com/image.jpg"
+
+
+def test_event_response_with_custom_error_handling(lambda_context, mock_event):
+ """Test handling events with custom error handling logic."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "sensitive data"}},
+ ]
+
+ # GIVEN a custom exception and a router with an async handler
+ class CustomSecurityException(Exception):
+ pass
+
+ router = Router()
+
+ @router.async_on_publish(path="/default/*")
+ async def security_check(payload):
+ # Simulate a security check that blocks certain IDs
+ blocked_data = ["sensitive data"]
+ if payload["data"] in blocked_data:
+ raise CustomSecurityException("Security check failed: Blocked ID")
+
+ await asyncio.sleep(0.01) # Simulate async work
+ return {"security_verified": True, "data": payload["data"]}
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get a security error response
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert "error" in result["events"][0]
+ assert "CustomSecurityException - Security check failed" in result["events"][0]["error"]
+ assert result["events"][0]["id"] == "123"
+
+
+def test_pattern_matching_no_valid_paths(lambda_context, mock_event):
+ """Test that path pattern matching works correctly with wildcards."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/users/123/notifications/new"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "user notification data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with wildcard path patterns
+ app = AppSyncEventsResolver()
+
+ # Define multiple resolvers with different path patterns
+ @app.on_publish(path="/users/*/notifications/*") # Should not match
+ def user_notification_handler(payload):
+ return {"handler": "wildcard_match", "data": "modified data 1"}
+
+ @app.on_publish(path="/users/123/messages/*") # Should not match
+ def user_message_handler(payload):
+ return {"handler": "wrong_path", "data": "modified data 2"}
+
+ @app.on_publish(path="/*/*/*") # should not match
+ def generic_handler(payload):
+ return {"handler": "generic", "data": "modified data 3"}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN no resolver is found and we return as is
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"data": "user notification data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_nested_async_functions(lambda_context, mock_event):
+ """Test that nested async functions work correctly within resolvers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/nested"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver that uses nested async functions
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def outer_handler(payload):
+ # Define nested async functions
+ async def validate_data(data):
+ await asyncio.sleep(0.01) # Simulate validation
+ return data.strip() != ""
+
+ async def transform_data(data):
+ await asyncio.sleep(0.01) # Simulate transformation
+ return data.upper()
+
+ # Use nested async functions
+ is_valid = await validate_data(payload["data"])
+ if not is_valid:
+ return {"error": "Invalid data"}
+
+ transformed = await transform_data(payload["data"])
+ return {"validated": is_valid, "transformed": transformed}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the nested async functions should execute correctly
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert result["events"][0]["payload"]["validated"] is True
+ assert result["events"][0]["payload"]["transformed"] == "TEST DATA"
+
+
+def test_concurrent_event_processing(lambda_context, mock_event):
+ """Test that multiple events are processed concurrently with async handlers."""
+ # GIVEN a sample publish event with multiple items that take different times to process
+ mock_event["info"]["channel"]["path"] = "/default/concurrent"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "fast data", "delay": 0.01}},
+ {"id": "456", "payload": {"data": "slow data", "delay": 0.03}},
+ {"id": "789", "payload": {"data": "medium data", "delay": 0.02}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async handler
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def process_with_variable_delay(payload):
+ # Simulate processing with different delays
+ await asyncio.sleep(payload["delay"])
+ return {
+ "processed": True,
+ "data": payload["data"],
+ "processing_time": payload["delay"],
+ }
+
+ # WHEN we resolve the event
+ import time
+
+ start_time = time.time()
+ result = app.resolve(mock_event, lambda_context)
+ end_time = time.time()
+
+ # THEN all events should be processed
+ assert "events" in result
+ assert len(result["events"]) == 3
+
+ # The total time should be roughly equal to the longest individual delay
+ # (not the sum of all delays, which would indicate sequential processing)
+ processing_time = end_time - start_time
+ assert processing_time < 0.1 # Should be close to the max delay (0.03) plus overhead
+
+ # Check all events were processed
+ ids = [event.get("id") for event in result["events"]]
+ assert set(ids) == {"123", "456", "789"}
+
+
+def test_handler_with_implicit_call_method_in_lambda_function(lambda_context, mock_event):
+ """Test that the __call__ method works correctly as an implicit Lambda handler."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"processed": True, "data": payload["data"]}
+
+ # Define a Lambda handler using the app directly
+ def lambda_handler(event, context):
+ return app(event, context) # Using __call__ method
+
+ # WHEN we call the lambda handler
+ result = lambda_handler(mock_event, lambda_context)
+
+ # THEN we should get the expected result
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_middleware_like_functionality(lambda_context, mock_event):
+ """Test implementing middleware-like functionality with context."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ # Simulate middleware by adding context before processing
+ def add_request_metadata(event, context, app):
+ app.append_context(
+ request_id="req-123",
+ timestamp=123456789,
+ user_agent="test-agent",
+ )
+
+ # Handler that uses the context added by middleware
+ @app.on_publish(path="/default/*")
+ def handler_with_middleware_data(payload):
+ return {
+ "processed": True,
+ "data": payload["data"],
+ "metadata": {
+ "request_id": app.context.get("request_id"),
+ "timestamp": app.context.get("timestamp"),
+ "user_agent": app.context.get("user_agent"),
+ },
+ }
+
+ # WHEN we add middleware data and resolve the event
+ add_request_metadata(mock_event, lambda_context, app)
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the handler should have access to middleware-added context
+ expected_metadata = {
+ "request_id": "req-123",
+ "timestamp": 123456789,
+ "user_agent": "test-agent",
+ }
+
+ assert result["events"][0]["payload"]["metadata"] == expected_metadata
+
+
+def test_handler_with_event_transformation(lambda_context, mock_event):
+ """Test handlers that transform event data before processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/transform"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"user_data": {"name": "John", "age": 30}}},
+ {"id": "456", "payload": {"user_data": {"name": "Jane", "age": 16}}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a router
+ router = Router()
+
+ # Add middleware context to transform data
+ @router.on_publish(path="/default/*", aggregate=True)
+ def transform_and_process(payload):
+ # Transform the payload structure
+ transformed = []
+ for item in payload:
+ transformed.append(
+ {
+ "id": item["id"],
+ "payload": {
+ "user_data": {
+ "fullName": item["payload"]["user_data"]["name"],
+ "userAge": item["payload"]["user_data"]["age"],
+ "isAdult": item["payload"]["user_data"]["age"] >= 18,
+ },
+ },
+ },
+ )
+ return transformed
+
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the data should be transformed
+ assert "events" in result
+ assert len(result["events"]) == 2
+
+ # Check transformation results
+ assert result["events"][0]["id"] == "123"
+ assert result["events"][0]["payload"]["user_data"]["fullName"] == "John"
+ assert result["events"][0]["payload"]["user_data"]["userAge"] == 30
+ assert result["events"][0]["payload"]["user_data"]["isAdult"] is True
+
+ assert result["events"][1]["id"] == "456"
+ assert result["events"][1]["payload"]["user_data"]["fullName"] == "Jane"
+ assert result["events"][1]["payload"]["user_data"]["userAge"] == 16
+ assert result["events"][1]["payload"]["user_data"]["isAdult"] is False
+
+
+def test_empty_events_payload(lambda_context, mock_event):
+ """Test handling events with an empty payload."""
+ # GIVEN a sample publish event with empty events
+ mock_event["events"] = []
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def handle_events(payload):
+ # Should handle empty payload gracefully
+ if payload == [{}]:
+ return []
+ return [{"processed": True} for _ in payload]
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an empty events list
+ assert "events" in result
+ assert result["events"] == []
+
+
+def test_multiple_related_routes_with_precedence(lambda_context, mock_event):
+ """Test event routing when multiple paths could match an event."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/products/electronics/phones/123"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"level": "phones", "data": "product data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with multiple related routes
+ app = AppSyncEventsResolver()
+
+ # Define resolvers with varying specificity
+ @app.on_publish(path="/products/*")
+ def general_product_handler(payload):
+ return {"level": "general", "data": payload["data"]}
+
+ @app.on_publish(path="/products/electronics/*")
+ def electronics_handler(payload):
+ return {"level": "electronics", "data": payload["data"]}
+
+ @app.on_publish(path="/products/electronics/phones/*")
+ def phones_handler(payload):
+ return {"level": "phones", "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the most specific matching path should be used
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"level": "phones", "data": "product data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_integration_with_external_service(lambda_context, mock_event):
+ """Test integration with an external service using mocks."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/orders/process"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"id": "order-123", "product_id": "prod-456", "quantity": 2}},
+ ]
+
+ # Mock an external service
+ class MockOrderService:
+ @staticmethod
+ async def process_order(order_id, product_id, quantity):
+ # Simulate processing delay
+ await asyncio.sleep(0.01)
+ return {
+ "order_id": order_id,
+ "status": "processed",
+ "total_amount": quantity * 10,
+ }
+
+ order_service = MockOrderService()
+
+ # GIVEN an AppSyncEventsResolver with an async resolver using the service
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/orders/*")
+ async def process_order(payload):
+ # Call the external service
+ result = await order_service.process_order(
+ order_id=payload["id"],
+ product_id=payload["product_id"],
+ quantity=payload["quantity"],
+ )
+ return {
+ "order_processed": True,
+ "order_details": result,
+ }
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the order should be processed with the external service
+ assert "events" in result
+ assert result["events"][0]["payload"]["order_processed"] is True
+ assert result["events"][0]["payload"]["order_details"]["order_id"] == "order-123"
+ assert result["events"][0]["payload"]["order_details"]["status"] == "processed"
+ assert result["events"][0]["payload"]["order_details"]["total_amount"] == 20 # 2 * 10
+
+
+def test_complex_resolver_hierarchy(lambda_context, mock_event):
+ """Test a complex setup with multiple routers and nested paths."""
+ # GIVEN a complex event
+ mock_event["info"]["channel"]["path"] = "/api/v1/users/profile/update"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"profile": {"name": "John Doe", "email": "john@example.com"}}},
+ ]
+
+ # GIVEN multiple routers for different API parts
+ base_router = Router()
+ users_router = Router()
+ profiles_router = Router()
+
+ # Add handlers to each router
+ @base_router.on_publish(path="/api/*")
+ def api_base_handler(payload):
+ return {"source": "base", "data": payload}
+
+ @users_router.on_publish(path="/api/v1/users/*")
+ def users_handler(payload):
+ return {"source": "users", "data": payload}
+
+ @profiles_router.on_publish(path="/api/v1/users/profile/*")
+ def profile_handler(payload):
+ # Do some profile-specific processing
+ return {
+ "source": "profiles",
+ "updated": True,
+ "profile": {
+ "fullName": payload["profile"]["name"],
+ "email": payload["profile"]["email"],
+ "timestamp": "2023-01-01T00:00:00Z",
+ },
+ }
+
+ # GIVEN an AppSyncEventsResolver with included routers
+ app = AppSyncEventsResolver()
+ app.include_router(base_router)
+ app.include_router(users_router)
+ app.include_router(profiles_router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the most specific router's handler should be used
+ assert "events" in result
+ assert result["events"][0]["id"] == "123"
+ assert result["events"][0]["payload"]["source"] == "profiles"
+ assert result["events"][0]["payload"]["updated"] is True
+ assert "fullName" in result["events"][0]["payload"]["profile"]
+ assert result["events"][0]["payload"]["profile"]["fullName"] == "John Doe"
+
+
+def test_warning_behavior_with_no_matching_resolver(lambda_context, mock_event):
+ """Test warning behavior when no matching resolver is found."""
+ # GIVEN a sample publish event with an unmatched path
+ mock_event["info"]["channel"]["path"] = "/unmatched/path"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver for a different path
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/matched/path")
+ def test_handler(payload):
+ return {"processed": True}
+
+ # WHEN we resolve the event
+ # THEN a warning should be generated
+ with pytest.warns(UserWarning, match="No resolvers were found for publish operations with path /unmatched/path"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # AND the payload should be returned as is
+ assert result == {"events": [{"id": "123", "payload": {"data": "test data"}}]}
+
+
+def test_resolver_precedence_with_exact_match(lambda_context, mock_event):
+ """Test that exact path matches have precedence over wildcard matches."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/notifications/system"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"message": "System notification"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with both wildcard and exact path resolvers
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/notifications/*")
+ def wildcard_handler(payload):
+ return {"source": "wildcard", "message": payload["message"]}
+
+ @app.on_publish(path="/notifications/system")
+ def exact_handler(payload):
+ return {"source": "exact", "message": payload["message"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the exact path match should take precedence
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"source": "exact", "message": "System notification"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_custom_routing_patterns(lambda_context, mock_event):
+ """Test custom routing patterns beyond simple wildcards."""
+ # GIVEN events with different path formats
+ event1 = deepcopy(mock_event)
+ event2 = deepcopy(mock_event)
+
+ event1["info"]["channel"]["path"] = "/users/123/posts/456"
+ event1["events"] = [
+ {"id": "123", "payload": {"data": "user post data"}},
+ ]
+
+ event2["info"]["channel"]["path"] = "/organizations/abc/members/xyz"
+ event2["events"] = [
+ {"id": "123", "payload": {"data": "organization member data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with pattern-based routing
+ app = AppSyncEventsResolver()
+
+ # Define resolvers for different entity patterns
+ @app.on_publish(path="/users/*")
+ def user_resource_handler(payload):
+ path = app.current_event.info.channel_path
+ segments = path.split("/")
+ user_id = segments[2]
+ resource_type = segments[3]
+
+ return {"entity_type": "user", "entity_id": user_id, "resource_type": resource_type, "data": payload["data"]}
+
+ @app.on_publish(path="/organizations/*")
+ def org_resource_handler(payload):
+ path = app.current_event.info.channel_path
+ segments = path.split("/")
+ org_id = segments[2]
+ resource_type = segments[3]
+
+ return {
+ "entity_type": "organization",
+ "entity_id": org_id,
+ "resource_type": resource_type,
+ "data": payload["data"],
+ }
+
+ # WHEN we resolve the events
+ result1 = app.resolve(event1, lambda_context)
+ result2 = app.resolve(event2, lambda_context)
+
+ # THEN each event should be handled by the appropriate pattern-based resolver
+ assert result1["events"][0]["payload"]["entity_type"] == "user"
+ assert result1["events"][0]["payload"]["entity_id"] == "123"
+ assert result1["events"][0]["payload"]["resource_type"] == "posts"
+
+ assert result2["events"][0]["payload"]["entity_type"] == "organization"
+ assert result2["events"][0]["payload"]["entity_id"] == "abc"
+ assert result2["events"][0]["payload"]["resource_type"] == "members"
+
+
+def test_warning_on_invalid_response_format(lambda_context, mock_event):
+ """Test warning generation for invalid response formats."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ {"id": "456", "payload": {"data": "more data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an aggregate handler that returns non-list
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def invalid_format_handler(payload):
+ # Incorrectly return a dict instead of a list
+ return {"processed": True, "count": len(payload)}
+
+ # WHEN we resolve the event
+ # THEN a warning should be generated about the response format
+ with pytest.warns(UserWarning, match="Response must be a list when using aggregate"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # The result should still contain what was returned
+ assert "events" in result
+ assert result["events"]["processed"] is True
+ assert result["events"]["count"] == 2
+
+
+def test_router_and_resolver_clear_context_after_resolution(lambda_context, mock_event):
+ """Test that both router and resolver's context are cleared after resolution."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with context data
+ router = Router()
+ router.append_context(router_key="router_value")
+
+ @router.on_publish(path="/default/*")
+ def router_handler(payload):
+ assert router.context["router_key"] == "router_value"
+ assert router.context["test_var"] == "app_value"
+ return {"processed": True}
+
+ # GIVEN an AppSyncEventsResolver with context data
+ app = AppSyncEventsResolver()
+ app.append_context(test_var="app_value")
+
+ # Include the router and merge contexts
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ app.resolve(mock_event, lambda_context)
+
+ # THEN both contexts should be cleared
+ assert app.context == {}
+ assert router.context == {}
+
+
+def test_sync_and_async_router_inclusion(lambda_context, mock_event):
+ """Test including multiple routers with both sync and async handlers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/notifications/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"message": "test notification"}},
+ ]
+
+ # GIVEN a router with synchronous handlers
+ sync_router = Router()
+
+ @sync_router.on_publish(path="/notifications/*")
+ def sync_handler(payload):
+ return {"sync": True, "message": payload["message"]}
+
+ # GIVEN another router with asynchronous handlers
+ async_router = Router()
+
+ @async_router.async_on_publish(path="/notifications/*")
+ async def async_handler(event):
+ await asyncio.sleep(0.01)
+ return {"async": True, "message": event["message"]}
+
+ # GIVEN an AppSyncEventsResolver that includes both routers
+ app = AppSyncEventsResolver()
+ app.include_router(sync_router)
+ app.include_router(async_router)
+
+ # WHEN we resolve the event
+ with pytest.warns(UserWarning, match="Both synchronous and asynchronous resolvers found"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the sync handler should take precedence
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"sync": True, "message": "test notification"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_aws_lambda_context_availability_in_handlers(lambda_context, mock_event):
+ """Test that Lambda context is available in handlers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a handler that uses Lambda context
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def context_aware_handler(payload):
+ # Access Lambda context information
+ return {
+ "processed": True,
+ "function_name": app.lambda_context.function_name,
+ "request_id": app.lambda_context.aws_request_id,
+ "function_arn": app.lambda_context.invoked_function_arn,
+ "payload_data": payload["data"],
+ }
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN Lambda context information should be included in the result
+ assert result["events"][0]["payload"]["function_name"] == lambda_context.function_name
+ assert result["events"][0]["payload"]["request_id"] == lambda_context.aws_request_id
+ assert result["events"][0]["payload"]["function_arn"] == lambda_context.invoked_function_arn
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+def test_router_lambda_context_shared(lambda_context, mock_event):
+ """Test that Lambda context is shared with included routers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/router/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with a handler that uses Lambda context
+ router = Router()
+
+ @router.on_publish(path="/router/*")
+ def router_context_handler(payload):
+ # Access Lambda context from the router
+ return {
+ "from_router": True,
+ "function_name": router.lambda_context.function_name,
+ "request_id": router.lambda_context.aws_request_id,
+ "payload_data": payload["data"],
+ }
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the router should have access to the same Lambda context
+ assert result["events"][0]["payload"]["from_router"] is True
+ assert result["events"][0]["payload"]["function_name"] == lambda_context.function_name
+ assert result["events"][0]["payload"]["request_id"] == lambda_context.aws_request_id
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+def test_current_event_availability(lambda_context, mock_event):
+ """Test that current_event is properly available to handlers."""
+ # GIVEN a sample publish event with extra metadata
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a handler that accesses current_event
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def event_aware_handler(payload):
+ # Access the full event object for additional context
+ return {
+ "processed": True,
+ "x-forwarded-for": app.current_event.request_headers["x-forwarded-for"],
+ "payload_data": payload["data"],
+ }
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the handler should have access to the full event information
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["x-forwarded-for"] == mock_event["request"]["headers"]["x-forwarded-for"]
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+def test_router_current_event_shared(lambda_context, mock_event):
+ """Test that current_event is shared with included routers."""
+ # GIVEN a sample publish event with extra metadata
+ mock_event["info"]["channel"]["path"] = "/router/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with a handler that accesses current_event
+ router = Router()
+
+ @router.on_publish(path="/router/*")
+ def router_event_handler(payload):
+ # Access event information from the router
+ return {
+ "processed": True,
+ "x-forwarded-for": app.current_event.request_headers["x-forwarded-for"],
+ "payload_data": payload["data"],
+ }
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the router should have access to the same event information
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["x-forwarded-for"] == mock_event["request"]["headers"]["x-forwarded-for"]
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+@pytest.mark.skip(reason="Not implemented yet")
+def test_channel_path_normalization(lambda_context, mock_event):
+ """Test that channel paths are properly normalized before matching."""
+ # GIVEN sample publish events with different path formats
+ event1 = deepcopy(mock_event)
+ event2 = deepcopy(mock_event)
+
+ event1["info"]["channel"]["path"] = "/test"
+ event1["events"] = [
+ {"id": "123", "payload": {"data": "data1"}},
+ ]
+
+ event2["info"]["channel"]["path"] = "/test/"
+ event2["events"] = [
+ {"id": "456", "payload": {"data": "data2"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a handler
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/test") # Register with path without trailing slash
+ def test_handler(payload):
+ return {"normalized": True, "data": payload["data"]}
+
+ # WHEN we resolve both events
+ result1 = app.resolve(event1, lambda_context)
+ result2 = app.resolve(event2, lambda_context)
+
+ # THEN both events should be handled consistently
+ expected_result1 = {
+ "events": [
+ {"id": "123", "payload": {"normalized": True, "data": "data1"}},
+ ],
+ }
+ assert result1 == expected_result1
+
+ # With proper normalization, this should also match
+ expected_result2 = {
+ "events": [
+ {"id": "456", "payload": {"normalized": True, "data": "data2"}},
+ ],
+ }
+ assert result2 == expected_result2
+
+
+def test_subscribe_event_with_error_handling(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/powertools"
+ del mock_event["events"] # SUBSCRIBE events are not supported
+
+ # GIVEN an AppSyncEventsResolver with a resolver that raises an exception
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/default/*")
+ def test_handler():
+ raise ValueError("Test error")
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert "error" in result
+ assert "ValueError - Test error" in result["error"]
+
+
+def test_subscribe_event_with_valid_return(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/powertools"
+
+ # GIVEN an AppSyncEventsResolver with a resolver that returns ok
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/default/*")
+ def test_handler():
+ return 1
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should return None because subscribe always must return None
+ assert result is None
+
+
+def test_subscribe_event_with_no_resolver(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/powertools"
+
+ # GIVEN an AppSyncEventsResolver with a resolver that returns ok
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/test")
+ def test_handler():
+ return 1
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert not result
+
+
+def test_publish_events_throw_unauthorized_exception(lambda_context, mock_event):
+ """Test handling events with an empty payload."""
+ # GIVEN a sample publish event with empty events
+ mock_event["info"]["operation"] = "PUBLISH"
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def handle_events(payload):
+ raise UnauthorizedException
+
+ # WHEN we resolve the event with unauthorized route
+ with pytest.raises(UnauthorizedException):
+ app.resolve(mock_event, lambda_context)
+
+
+def test_subscribe_events_throw_unauthorized_exception(lambda_context, mock_event):
+ """Test handling events with an empty payload."""
+ # GIVEN a sample publish event with empty events
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/test"
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/default/*")
+ def handle_events():
+ raise UnauthorizedException
+
+ # WHEN we resolve the event with unauthorized route
+ with pytest.raises(UnauthorizedException):
+ app.resolve(mock_event, lambda_context)
diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py
new file mode 100644
index 00000000000..4ef902c340a
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py
@@ -0,0 +1,389 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from aws_lambda_powertools.event_handler import AppSyncResolver
+from aws_lambda_powertools.event_handler.graphql_appsync.router import Router
+from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
+from aws_lambda_powertools.utilities.typing import LambdaContext
+from tests.functional.utils import load_event
+
+
+def test_direct_resolver():
+ # Check whether we can handle an example appsync direct resolver
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="createSomething")
+ def create_something(id: str): # noqa AA03 VNE003
+ assert app.lambda_context == {}
+ return id
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ assert result == "my identifier"
+
+
+def test_direct_resolver_with_parent_name():
+ # Check whether we can handle an example appsync direct resolver
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="createSomething", type_name="Mutation")
+ def create_something(id: str): # noqa AA03 VNE003
+ assert app.lambda_context == {}
+ return id
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ assert result == "my identifier"
+
+
+def test_custom_resolver_with_fields():
+ # Check whether we can handle an example appsync with custom resolver
+ mock_event = load_event("appSyncCustomResolverEvent.json")
+
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="locations", type_name="Merchant")
+ def create_something(page: int): # noqa AA03 VNE003
+ assert app.lambda_context == {}
+ return page
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ assert result == 2
+
+
+def test_amplify_resolver():
+ # Check whether we can handle an example appsync resolver
+ mock_event = load_event("appSyncResolverEvent.json")
+
+ app = AppSyncResolver()
+
+ @app.resolver(type_name="Merchant", field_name="locations")
+ def get_location(page: int, size: int, name: str):
+ assert app.current_event is not None
+ assert isinstance(app.current_event, AppSyncResolverEvent)
+ assert page == 2
+ assert size == 1
+ return name
+
+ def handler(event, context):
+ # Call the explicit resolve function
+ return app.resolve(event, context)
+
+ result = handler(mock_event, {})
+ assert result == "value"
+
+
+def test_resolver_no_params():
+ # GIVEN
+ app = AppSyncResolver()
+
+ @app.resolver(type_name="Query", field_name="noParams")
+ def no_params():
+ return "no_params has no params"
+
+ event = {"typeName": "Query", "fieldName": "noParams", "arguments": {}}
+
+ # WHEN
+ result = app.resolve(event, LambdaContext())
+
+ # THEN
+ assert result == "no_params has no params"
+
+
+def test_resolver_value_error():
+ # GIVEN no defined field resolver
+ app = AppSyncResolver()
+
+ # WHEN
+ with pytest.raises(ValueError) as exp:
+ event = {"typeName": "type", "fieldName": "field", "arguments": {}}
+ app.resolve(event, LambdaContext())
+
+ # THEN
+ assert exp.value.args[0] == "No resolver found for 'type.field'"
+
+
+def test_resolver_yield():
+ # GIVEN
+ app = AppSyncResolver()
+
+ mock_event = {"typeName": "Customer", "fieldName": "field", "arguments": {}}
+
+ @app.resolver(field_name="field")
+ def func_yield():
+ yield "value"
+
+ # WHEN
+ mock_context = LambdaContext()
+ result = app.resolve(mock_event, mock_context)
+
+ # THEN
+ assert next(result) == "value"
+
+
+def test_resolver_multiple_mappings():
+ # GIVEN
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="listLocations")
+ @app.resolver(field_name="locations")
+ def get_locations(name: str, description: str = ""):
+ return name + description
+
+ # WHEN
+ mock_event1 = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}}
+ mock_event2 = {
+ "typeName": "Merchant",
+ "fieldName": "locations",
+ "arguments": {"name": "value2", "description": "description"},
+ }
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN
+ assert result1 == "value"
+ assert result2 == "value2description"
+
+
+def test_resolver_async():
+ # GIVEN
+ app = AppSyncResolver()
+
+ mock_event = {"typeName": "Customer", "fieldName": "field", "arguments": {}}
+
+ @app.resolver(field_name="field")
+ async def get_async():
+ await asyncio.sleep(0.0001)
+ return "value"
+
+ # WHEN
+ mock_context = LambdaContext()
+ result = app.resolve(mock_event, mock_context)
+
+ # THEN
+ assert asyncio.run(result) == "value"
+
+
+def test_resolve_custom_data_model():
+ # Check whether we can handle an example appsync direct resolver
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ class MyCustomModel(AppSyncResolverEvent):
+ @property
+ def country_viewer(self) -> str:
+ return self.request_headers.get("cloudfront-viewer-country", "")
+
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="createSomething")
+ def create_something(id: str): # noqa AA03 VNE003
+ return id
+
+ # Call the implicit handler
+ result = app(event=mock_event, context=LambdaContext(), data_model=MyCustomModel)
+
+ assert result == "my identifier"
+
+ assert app.current_event.country_viewer == "US"
+
+
+def test_resolver_include_resolver():
+ # GIVEN
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.resolver(type_name="Query", field_name="listLocations")
+ def get_locations(name: str):
+ return f"get_locations#{name}"
+
+ @app.resolver(field_name="listLocations2")
+ def get_locations2(name: str):
+ return f"get_locations2#{name}"
+
+ app.include_router(router)
+
+ # WHEN
+ mock_event1 = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}}
+ mock_event2 = {"typeName": "Query", "fieldName": "listLocations2", "arguments": {"name": "value"}}
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN
+ assert result1 == "get_locations#value"
+ assert result2 == "get_locations2#value"
+
+
+def test_append_context():
+ app = AppSyncResolver()
+ app.append_context(is_admin=True)
+ assert app.context.get("is_admin") is True
+
+
+def test_router_append_context():
+ router = Router()
+ router.append_context(is_admin=True)
+ assert router.context.get("is_admin") is True
+
+
+def test_route_context_is_cleared_after_resolve():
+ # GIVEN
+ app = AppSyncResolver()
+ event = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}}
+
+ @app.resolver(field_name="listLocations")
+ def get_locations(name: str):
+ return f"get_locations#{name}"
+
+ # WHEN event resolution kicks in
+ app.append_context(is_admin=True)
+ app.resolve(event, {})
+
+ # THEN context should be empty
+ assert app.context == {}
+
+
+def test_router_has_access_to_app_context():
+ # GIVEN
+ app = AppSyncResolver()
+ router = Router()
+ event = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}}
+
+ @router.resolver(type_name="Query", field_name="listLocations")
+ def get_locations(name: str):
+ if router.context.get("is_admin"):
+ return f"get_locations#{name}"
+
+ app.include_router(router)
+
+ # WHEN
+ app.append_context(is_admin=True)
+ ret = app.resolve(event, {})
+
+ # THEN
+ assert ret == "get_locations#value"
+ assert router.context == {}
+
+
+def test_include_router_merges_context():
+ # GIVEN
+ app = AppSyncResolver()
+ router = Router()
+
+ # WHEN
+ app.append_context(is_admin=True)
+ router.append_context(product_access=True)
+
+ app.include_router(router)
+
+ assert app.context == router.context
+
+
+def test_include_router_access_current_event():
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ # GIVEN An instance of AppSyncResolver, a Router instance, and a resolver function registered with the router
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.resolver(field_name="createSomething")
+ def get_user(id: str) -> dict: # noqa AA03 VNE003
+ return router.current_event.identity.sub
+
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the current_event
+ assert ret == mock_event["identity"]["sub"]
+
+
+def test_app_access_current_event():
+ # Check whether we can handle an example appsync direct resolver
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="createSomething")
+ def get_user(id: str) -> dict: # noqa AA03 VNE003
+ return app.current_event.identity.sub
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the current_event
+ assert ret == mock_event["identity"]["sub"]
+
+
+def test_route_context_is_not_cleared_after_resolve_async():
+ # GIVEN
+ app = AppSyncResolver()
+ event = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}}
+
+ @app.resolver(field_name="listLocations")
+ async def get_locations(name: str):
+ return f"get_locations#{name}"
+
+ # WHEN event resolution kicks in
+ app.append_context(is_admin=True)
+ app.resolve(event, {})
+
+ # THEN context should be empty
+ assert app.context == {"is_admin": True}
+
+
+def test_route_context_is_manually_cleared_after_resolve_async():
+ # GIVEN
+ # GIVEN
+ app = AppSyncResolver()
+
+ mock_event = {"typeName": "Customer", "fieldName": "field", "arguments": {}}
+
+ @app.resolver(field_name="field")
+ async def get_async():
+ app.context.clear()
+ await asyncio.sleep(0.0001)
+ return "value"
+
+ # WHEN
+ mock_context = LambdaContext()
+ app.append_context(is_admin=True)
+ result = app.resolve(mock_event, mock_context)
+
+ # THEN
+ assert asyncio.run(result) == "value"
+ assert app.context == {}
+
+
+def test_exception_handler_with_single_resolver():
+ # GIVEN a AppSyncResolver instance
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ app = AppSyncResolver()
+
+ # WHEN we configure exception handler for ValueError
+ @app.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ return {"message": "error"}
+
+ @app.resolver(field_name="createSomething")
+ def create_something(id: str): # noqa AA03 VNE003
+ raise ValueError("Error")
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ # THEN the return must be the Exception Handler error message
+ assert result["message"] == "error"
diff --git a/tests/functional/event_handler/required_dependencies/conftest.py b/tests/functional/event_handler/required_dependencies/conftest.py
new file mode 100644
index 00000000000..5c2bdb7729a
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/conftest.py
@@ -0,0 +1,73 @@
+import json
+
+import pytest
+
+from tests.functional.utils import load_event
+
+
+@pytest.fixture
+def json_dump():
+ # our serializers reduce length to save on costs; fixture to replicate separators
+ return lambda obj: json.dumps(obj, separators=(",", ":"))
+
+
+@pytest.fixture
+def validation_schema():
+ return {
+ "$schema": "https://json-schema.org/draft-07/schema",
+ "$id": "https://example.com/example.json",
+ "type": "object",
+ "title": "Sample schema",
+ "description": "The root schema comprises the entire JSON document.",
+ "examples": [{"message": "hello world", "username": "lessa"}],
+ "required": ["message", "username"],
+ "properties": {
+ "message": {
+ "$id": "#/properties/message",
+ "type": "string",
+ "title": "The message",
+ "examples": ["hello world"],
+ },
+ "username": {
+ "$id": "#/properties/username",
+ "type": "string",
+ "title": "The username",
+ "examples": ["lessa"],
+ },
+ },
+ }
+
+
+@pytest.fixture
+def raw_event():
+ return {"message": "hello hello", "username": "blah blah"}
+
+
+@pytest.fixture
+def gw_event():
+ return load_event("apiGatewayProxyEvent.json")
+
+
+@pytest.fixture
+def gw_event_http():
+ return load_event("apiGatewayProxyV2Event.json")
+
+
+@pytest.fixture
+def gw_event_alb():
+ return load_event("albMultiValueQueryStringEvent.json")
+
+
+@pytest.fixture
+def gw_event_lambda_url():
+ return load_event("lambdaFunctionUrlEventWithHeaders.json")
+
+
+@pytest.fixture
+def gw_event_vpc_lattice():
+ return load_event("vpcLatticeV2EventWithHeaders.json")
+
+
+@pytest.fixture
+def gw_event_vpc_lattice_v1():
+ return load_event("vpcLatticeEvent.json")
diff --git a/tests/functional/event_handler/required_dependencies/test_api_gateway.py b/tests/functional/event_handler/required_dependencies/test_api_gateway.py
new file mode 100644
index 00000000000..e5ed7b7cb78
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py
@@ -0,0 +1,2002 @@
+from __future__ import annotations
+
+import base64
+import json
+import re
+import zlib
+from collections import deque
+from copy import deepcopy
+from decimal import Decimal
+from enum import Enum
+from functools import partial
+from json import JSONEncoder
+from pathlib import Path
+
+import pytest
+
+from aws_lambda_powertools.event_handler import (
+ content_types,
+)
+from aws_lambda_powertools.event_handler.api_gateway import (
+ ALBResolver,
+ APIGatewayHttpResolver,
+ ApiGatewayResolver,
+ APIGatewayRestResolver,
+ CORSConfig,
+ ProxyEventType,
+ Response,
+ ResponseBuilder,
+ Router,
+)
+from aws_lambda_powertools.event_handler.exceptions import (
+ BadRequestError,
+ ForbiddenError,
+ InternalServerError,
+ NotFoundError,
+ RequestEntityTooLargeError,
+ RequestTimeoutError,
+ ServiceError,
+ ServiceUnavailableError,
+ UnauthorizedError,
+)
+from aws_lambda_powertools.shared import constants
+from aws_lambda_powertools.shared.cookies import Cookie
+from aws_lambda_powertools.shared.json_encoder import Encoder
+from aws_lambda_powertools.utilities.data_classes import (
+ ALBEvent,
+ APIGatewayProxyEvent,
+ APIGatewayProxyEventV2,
+ event_source,
+)
+from tests.functional.utils import load_event
+
+
+def read_media(file_name: str) -> bytes:
+ path = Path(f"{str(Path(__file__).parent.parent.parent.parent)}/../docs/media/{file_name}")
+ return path.read_bytes()
+
+
+LOAD_GW_EVENT = load_event("apiGatewayProxyEvent.json")
+LOAD_GW_EVENT_NO_ORIGIN = load_event("apiGatewayProxyEventNoOrigin.json")
+LOAD_GW_EVENT_TRAILING_SLASH = load_event("apiGatewayProxyEventPathTrailingSlash.json")
+
+
+def test_alb_event():
+ # GIVEN an Application Load Balancer proxy type event
+ app = ALBResolver()
+
+ @app.get("/lambda")
+ def foo():
+ assert isinstance(app.current_event, ALBEvent)
+ assert app.lambda_context == {}
+ assert app.current_event.request_context.elb_target_group_arn is not None
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(load_event("albEvent.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as ALBEvent
+ assert result["statusCode"] == 200
+ assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert result["body"] == "foo"
+
+
+def test_alb_event_path_trailing_slash(json_dump):
+ # GIVEN an Application Load Balancer proxy type event
+ app = ALBResolver()
+
+ @app.get("/lambda")
+ def foo():
+ assert isinstance(app.current_event, ALBEvent)
+ assert app.lambda_context == {}
+ assert app.current_event.request_context.elb_target_group_arn is not None
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler using path with trailing "/"
+ result = app(load_event("albEventPathTrailingSlash.json"), {})
+
+ # THEN
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_api_gateway_v1():
+ # GIVEN a Http API V1 proxy type event
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEvent)
+ assert app.lambda_context == {}
+ assert app.current_event.request_context.domain_name == "id.execute-api.us-east-1.amazonaws.com"
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"}))
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+
+
+def test_api_gateway_v1_path_trailing_slash():
+ # GIVEN a Http API V1 proxy type event
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"}))
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT_TRAILING_SLASH, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+
+
+def test_api_gateway_v1_cookies():
+ # GIVEN a Http API V1 proxy type event
+ app = APIGatewayRestResolver()
+ cookie = Cookie(name="CookieMonster", value="MonsterCookie")
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEvent)
+ return Response(200, content_types.TEXT_PLAIN, "Hello world", cookies=[cookie])
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Set-Cookie"] == ["CookieMonster=MonsterCookie; Secure"]
+
+
+def test_api_gateway():
+ # GIVEN a Rest API Gateway proxy type event
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEvent)
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML]
+ assert result["body"] == "foo"
+
+
+def test_api_gateway_event_path_trailing_slash(json_dump):
+ # GIVEN a Rest API Gateway proxy type event
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEvent)
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT_TRAILING_SLASH, {})
+ # THEN
+ assert result["statusCode"] == 404
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_api_gateway_v2():
+ # GIVEN a Http API V2 proxy type event
+ app = APIGatewayHttpResolver()
+
+ @app.post("/my/path")
+ def my_path() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEventV2)
+ post_data = app.current_event.json_body
+ assert app.current_event.cookies[0] == "cookie1"
+ return Response(200, content_types.TEXT_PLAIN, post_data["username"])
+
+ # WHEN calling the event handler
+ result = app(load_event("apiGatewayProxyV2Event.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEventV2
+ assert result["statusCode"] == 200
+ assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN
+ assert "Cookies" not in result["headers"]
+ assert result["body"] == "tom"
+
+
+def test_api_gateway_v2_http_path_trailing_slash(json_dump):
+ # GIVEN a Http API V2 proxy type event
+ app = APIGatewayHttpResolver()
+
+ @app.post("/my/path")
+ def my_path() -> Response:
+ post_data = app.current_event.json_body
+ return Response(200, content_types.TEXT_PLAIN, post_data["username"])
+
+ # WHEN calling the event handler
+ result = app(load_event("apiGatewayProxyV2EventPathTrailingSlash.json"), {})
+
+ # THEN expect a 404 response
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_api_gateway_v2_cookies():
+ # GIVEN a Http API V2 proxy type event
+ app = APIGatewayHttpResolver()
+ cookie = Cookie(name="CookieMonster", value="MonsterCookie")
+
+ @app.post("/my/path")
+ def my_path() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEventV2)
+ return Response(200, content_types.TEXT_PLAIN, "Hello world", cookies=[cookie])
+
+ # WHEN calling the event handler
+ result = app(load_event("apiGatewayProxyV2Event.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEventV2
+ assert result["statusCode"] == 200
+ assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN
+ assert result["cookies"] == ["CookieMonster=MonsterCookie; Secure"]
+
+
+def test_include_rule_matching():
+ # GIVEN
+ app = ApiGatewayResolver()
+
+ @app.get("/