From 765781cb16338a72c5c27d292bfbc5ba6f68521b Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 23 Nov 2023 14:47:41 +0100 Subject: [PATCH] docs(event_handlers): new data validation and OpenAPI feature (#3386) Co-authored-by: heitorlessa --- .../event_handler/openapi/params.py | 174 ++++++++++ docs/core/event_handler/api_gateway.md | 327 +++++++++++++++++- docs/media/swagger.png | Bin 0 -> 54463 bytes .../sam/template.yaml | 4 +- examples/event_handler_rest/sam/template.yaml | 18 + .../src/customizing_api_metadata.py | 33 ++ .../src/customizing_api_operations.py | 30 ++ .../src/customizing_swagger.py | 29 ++ .../src/customizing_swagger_middlewares.py | 40 +++ .../src/data_validation.json | 36 ++ .../event_handler_rest/src/data_validation.py | 35 ++ .../src/data_validation_error.json | 42 +++ ...a_validation_error_unsanitized_output.json | 9 + .../src/data_validation_output.json | 10 + .../src/data_validation_sanitized_error.py | 46 +++ ...ata_validation_sanitized_error_output.json | 9 + .../src/enabling_swagger.py | 40 +++ .../src/skip_validating_query_strings.py | 40 +++ .../event_handler_rest/src/validating_path.py | 37 ++ .../src/validating_payload_subset.json | 36 ++ .../src/validating_payload_subset.py | 30 ++ .../src/validating_payload_subset_output.json | 10 + .../src/validating_payloads.json | 36 ++ .../src/validating_payloads.py | 43 +++ .../src/validating_payloads_output.json | 10 + .../src/validating_query_strings.py | 42 +++ 26 files changed, 1162 insertions(+), 4 deletions(-) create mode 100644 docs/media/swagger.png create mode 100644 examples/event_handler_rest/src/customizing_api_metadata.py create mode 100644 examples/event_handler_rest/src/customizing_api_operations.py create mode 100644 examples/event_handler_rest/src/customizing_swagger.py create mode 100644 examples/event_handler_rest/src/customizing_swagger_middlewares.py create mode 100644 examples/event_handler_rest/src/data_validation.json create mode 100644 examples/event_handler_rest/src/data_validation.py create mode 100644 examples/event_handler_rest/src/data_validation_error.json create mode 100644 examples/event_handler_rest/src/data_validation_error_unsanitized_output.json create mode 100644 examples/event_handler_rest/src/data_validation_output.json create mode 100644 examples/event_handler_rest/src/data_validation_sanitized_error.py create mode 100644 examples/event_handler_rest/src/data_validation_sanitized_error_output.json create mode 100644 examples/event_handler_rest/src/enabling_swagger.py create mode 100644 examples/event_handler_rest/src/skip_validating_query_strings.py create mode 100644 examples/event_handler_rest/src/validating_path.py create mode 100644 examples/event_handler_rest/src/validating_payload_subset.json create mode 100644 examples/event_handler_rest/src/validating_payload_subset.py create mode 100644 examples/event_handler_rest/src/validating_payload_subset_output.json create mode 100644 examples/event_handler_rest/src/validating_payloads.json create mode 100644 examples/event_handler_rest/src/validating_payloads.py create mode 100644 examples/event_handler_rest/src/validating_payloads_output.json create mode 100644 examples/event_handler_rest/src/validating_query_strings.py diff --git a/aws_lambda_powertools/event_handler/openapi/params.py b/aws_lambda_powertools/event_handler/openapi/params.py index f267a4841f..bd542ba793 100644 --- a/aws_lambda_powertools/event_handler/openapi/params.py +++ b/aws_lambda_powertools/event_handler/openapi/params.py @@ -117,6 +117,64 @@ def __init__( json_schema_extra: Union[Dict[str, Any], None] = None, **extra: Any, ): + """ + Constructs a new Param. + + Parameters + ---------- + default: Any + The default value of the parameter + default_factory: Callable[[], Any], optional + Callable that will be called when a default value is needed for this field + annotation: Any, optional + The type annotation of the parameter + alias: str, optional + The public name of the field + alias_priority: int, optional + Priority of the alias. This affects whether an alias generator is used + validation_alias: str | AliasPath | AliasChoices | None, optional + Alias to be used for validation only + serialization_alias: str | AliasPath | AliasChoices | None, optional + Alias to be used for serialization only + title: str, optional + The title of the parameter + description: str, optional + The description of the parameter + gt: float, optional + Only applies to numbers, required the field to be "greater than" + ge: float, optional + Only applies to numbers, required the field to be "greater than or equal" + lt: float, optional + Only applies to numbers, required the field to be "less than" + le: float, optional + Only applies to numbers, required the field to be "less than or equal" + min_length: int, optional + Only applies to strings, required the field to have a minimum length + max_length: int, optional + Only applies to strings, required the field to have a maximum length + pattern: str, optional + Only applies to strings, requires the field match against a regular expression pattern string + discriminator: str, optional + Parameter field name for discriminating the type in a tagged union + strict: bool, optional + Enables Pydantic's strict mode for the field + multiple_of: float, optional + Only applies to numbers, requires the field to be a multiple of the given value + allow_inf_nan: bool, optional + Only applies to numbers, requires the field to allow infinity and NaN values + max_digits: int, optional + Only applies to Decimals, requires the field to have a maxmium number of digits within the decimal. + decimal_places: int, optional + Only applies to Decimals, requires the field to have at most a number of decimal places + examples: List[Any], optional + A list of examples for the parameter + deprecated: bool, optional + If `True`, the parameter will be marked as deprecated + include_in_schema: bool, optional + If `False`, the parameter will be excluded from the generated OpenAPI schema + json_schema_extra: Dict[str, Any], optional + Extra values to include in the generated OpenAPI schema + """ self.deprecated = deprecated self.include_in_schema = include_in_schema @@ -207,6 +265,64 @@ def __init__( json_schema_extra: Union[Dict[str, Any], None] = None, **extra: Any, ): + """ + Constructs a new Path param. + + Parameters + ---------- + default: Any + The default value of the parameter + default_factory: Callable[[], Any], optional + Callable that will be called when a default value is needed for this field + annotation: Any, optional + The type annotation of the parameter + alias: str, optional + The public name of the field + alias_priority: int, optional + Priority of the alias. This affects whether an alias generator is used + validation_alias: str | AliasPath | AliasChoices | None, optional + Alias to be used for validation only + serialization_alias: str | AliasPath | AliasChoices | None, optional + Alias to be used for serialization only + title: str, optional + The title of the parameter + description: str, optional + The description of the parameter + gt: float, optional + Only applies to numbers, required the field to be "greater than" + ge: float, optional + Only applies to numbers, required the field to be "greater than or equal" + lt: float, optional + Only applies to numbers, required the field to be "less than" + le: float, optional + Only applies to numbers, required the field to be "less than or equal" + min_length: int, optional + Only applies to strings, required the field to have a minimum length + max_length: int, optional + Only applies to strings, required the field to have a maximum length + pattern: str, optional + Only applies to strings, requires the field match against a regular expression pattern string + discriminator: str, optional + Parameter field name for discriminating the type in a tagged union + strict: bool, optional + Enables Pydantic's strict mode for the field + multiple_of: float, optional + Only applies to numbers, requires the field to be a multiple of the given value + allow_inf_nan: bool, optional + Only applies to numbers, requires the field to allow infinity and NaN values + max_digits: int, optional + Only applies to Decimals, requires the field to have a maxmium number of digits within the decimal. + decimal_places: int, optional + Only applies to Decimals, requires the field to have at most a number of decimal places + examples: List[Any], optional + A list of examples for the parameter + deprecated: bool, optional + If `True`, the parameter will be marked as deprecated + include_in_schema: bool, optional + If `False`, the parameter will be excluded from the generated OpenAPI schema + json_schema_extra: Dict[str, Any], optional + Extra values to include in the generated OpenAPI schema + """ if default is not ...: raise AssertionError("Path parameters cannot have a default value") @@ -279,6 +395,64 @@ def __init__( json_schema_extra: Union[Dict[str, Any], None] = None, **extra: Any, ): + """ + Constructs a new Query param. + + Parameters + ---------- + default: Any + The default value of the parameter + default_factory: Callable[[], Any], optional + Callable that will be called when a default value is needed for this field + annotation: Any, optional + The type annotation of the parameter + alias: str, optional + The public name of the field + alias_priority: int, optional + Priority of the alias. This affects whether an alias generator is used + validation_alias: str | AliasPath | AliasChoices | None, optional + Alias to be used for validation only + serialization_alias: str | AliasPath | AliasChoices | None, optional + Alias to be used for serialization only + title: str, optional + The title of the parameter + description: str, optional + The description of the parameter + gt: float, optional + Only applies to numbers, required the field to be "greater than" + ge: float, optional + Only applies to numbers, required the field to be "greater than or equal" + lt: float, optional + Only applies to numbers, required the field to be "less than" + le: float, optional + Only applies to numbers, required the field to be "less than or equal" + min_length: int, optional + Only applies to strings, required the field to have a minimum length + max_length: int, optional + Only applies to strings, required the field to have a maximum length + pattern: str, optional + Only applies to strings, requires the field match against a regular expression pattern string + discriminator: str, optional + Parameter field name for discriminating the type in a tagged union + strict: bool, optional + Enables Pydantic's strict mode for the field + multiple_of: float, optional + Only applies to numbers, requires the field to be a multiple of the given value + allow_inf_nan: bool, optional + Only applies to numbers, requires the field to allow infinity and NaN values + max_digits: int, optional + Only applies to Decimals, requires the field to have a maxmium number of digits within the decimal. + decimal_places: int, optional + Only applies to Decimals, requires the field to have at most a number of decimal places + examples: List[Any], optional + A list of examples for the parameter + deprecated: bool, optional + If `True`, the parameter will be marked as deprecated + include_in_schema: bool, optional + If `False`, the parameter will be excluded from the generated OpenAPI schema + json_schema_extra: Dict[str, Any], optional + Extra values to include in the generated OpenAPI schema + """ super().__init__( default=default, default_factory=default_factory, diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 6868ce25d4..005ac3a4b7 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -11,12 +11,21 @@ Event handler for Amazon API Gateway REST and HTTP APIs, Application Loader Bala * Support for CORS, binary and Gzip compression, Decimals JSON encoding and bring your own JSON serializer * Built-in integration with [Event Source Data Classes utilities](../../utilities/data_classes.md){target="_blank"} for self-documented event schema * Works with micro function (one or a few routes) and monolithic functions (all routes) +* Support for OpenAPI and data validation for requests/responses ## Getting started ???+ tip All examples shared in this documentation are available within the [project repository](https://github.com/aws-powertools/powertools-lambda-python/tree/develop/examples){target="_blank"}. +### Install + +!!! info "This is not necessary if you're installing Powertools for AWS Lambda (Python) via [Lambda Layer/SAR](../../index.md#lambda-layer){target="_blank"}." + +**When using the data validation feature**, you need to add `pydantic` as a dependency in your preferred tool _e.g., requirements.txt, pyproject.toml_. + +As of now, both Pydantic V1 and V2 are supported. For a future major version, we will only support Pydantic V2. + ### Required resources @@ -221,6 +230,190 @@ If you need to accept multiple HTTP methods in a single function, you can use th ???+ note It is generally better to have separate functions for each HTTP method, as the functionality tends to differ depending on which method is used. +### Data validation + +!!! note "This changes the authoring experience by relying on Python's type annotations" + It's inspired by [FastAPI framework](https://fastapi.tiangolo.com/){target="_blank" rel="nofollow"} for ergonomics and to ease migrations in either direction. We support both Pydantic models and Python's dataclass. + + For brevity, we'll focus on Pydantic only. + +All resolvers can optionally coerce and validate incoming requests by setting `enable_validation=True`. + +With this feature, we can now express how we expect our incoming data and response to look like. This moves data validation responsibilities to Event Handler resolvers, reducing a ton of boilerplate code. + +Let's rewrite the previous examples to signal our resolver what shape we expect our data to be. + + + +=== "data_validation.py" + + ```python hl_lines="13 16 25 29" + --8<-- "examples/event_handler_rest/src/data_validation.py" + ``` + + 1. This enforces data validation at runtime. Any validation error will return `HTTP 422: Unprocessable Entity error`. + 2. We create a Pydantic model to define how our data looks like. + 3. Defining a route remains exactly as before. + 4. By default, URL Paths will be `str`. Here, we are telling our resolver it should be `int`, so it converts it for us.

Lastly, we're also saying the return should be our `Todo`. This will help us later when we touch OpenAPI auto-documentation. + 5. `todo.json()` returns a dictionary. However, Event Handler knows the response should be `Todo` so it converts and validates accordingly. + +=== "data_validation.json" + + ```json hl_lines="4" + --8<-- "examples/event_handler_rest/src/data_validation.json" + ``` + +=== "data_validation_output.json" + + ```json hl_lines="2-3" + --8<-- "examples/event_handler_rest/src/data_validation_output.json" + ``` + + + +#### Handling validation errors + +!!! info "By default, we hide extended error details for security reasons _(e.g., pydantic url, Pydantic code)_." + +Any incoming request that fails validation will lead to a `HTTP 422: Unprocessable Entity error` response that will look similar to this: + +```json hl_lines="2 3" title="data_validation_error_unsanitized_output.json" +--8<-- "examples/event_handler_rest/src/data_validation_error_unsanitized_output.json" +``` + +You can customize the error message by catching the `RequestValidationError` exception. This is useful when you might have a security policy to return opaque validation errors, or have a company standard for API validation errors. + +Here's an example where we catch validation errors, log all details for further investigation, and return the same `HTTP 422` with an opaque error. + +=== "data_validation_sanitized_error.py" + + Note that Pydantic versions [1](https://docs.pydantic.dev/1.10/usage/models/#error-handling){target="_blank" rel="nofollow"} and [2](https://docs.pydantic.dev/latest/errors/errors/){target="_blank" rel="nofollow"} report validation detailed errors differently. + + ```python hl_lines="8 24-25 31" + --8<-- "examples/event_handler_rest/src/data_validation_sanitized_error.py" + ``` + + 1. We use [exception handler](#exception-handling) decorator to catch **any** request validation errors.

Then, we log the detailed reason as to why it failed while returning a custom `Response` object to hide that from them. + +=== "data_validation_sanitized_error_output.json" + + ```json hl_lines="2 3" + --8<-- "examples/event_handler_rest/src/data_validation_sanitized_error_output.json" + ``` + +#### Validating payloads + +!!! info "We will automatically validate, inject, and convert incoming request payloads based on models via type annotation." + +Let's improve our previous example by handling the creation of todo items via `HTTP POST`. + +What we want is for Event Handler to convert the incoming payload as an instance of our `Todo` model. We handle the creation of that `todo`, and then return the `ID` of the newly created `todo`. + +Even better, we can also let Event Handler validate and convert our response according to type annotations, further reducing boilerplate. + +=== "validating_payloads.py" + + ```python hl_lines="13 16 24 33" + --8<-- "examples/event_handler_rest/src/validating_payloads.py" + ``` + + 1. This enforces data validation at runtime. Any validation error will return `HTTP 422: Unprocessable Entity error`. + 2. We create a Pydantic model to define how our data looks like. + 3. We define `Todo` as our type annotation. Event Handler then uses this model to validate and inject the incoming request as `Todo`. + 4. Lastly, we return the ID of our newly created `todo` item.

Because we specify the return type (`str`), Event Handler will take care of serializing this as a JSON string. + 5. Note that the return type is `List[Todo]`.

Event Handler will take the return (`todo.json`), and validate each list item against `Todo` model before returning the response accordingly. + +=== "validating_payloads.json" + + ```json hl_lines="3 5-6" + --8<-- "examples/event_handler_rest/src/validating_payloads.json" + ``` + +=== "validating_payloads_output.json" + + ```json hl_lines="3" + --8<-- "examples/event_handler_rest/src/validating_payloads_output.json" + ``` + +##### Validating payload subset + +With the addition of the [`Annotated` type starting in Python 3.9](https://docs.python.org/3/library/typing.html#typing.Annotated){target="_blank" rel="nofollow"}, types can contain additional metadata, allowing us to represent anything we want. + +We use the `Annotated` and OpenAPI `Body` type to instruct Event Handler that our payload is located in a particular JSON key. + +!!! note "Event Handler will match the parameter name with the JSON key to validate and inject what you want." + +=== "validating_payload_subset.py" + + ```python hl_lines="7 8 22" + --8<-- "examples/event_handler_rest/src/validating_payload_subset.py" + ``` + + 1. `Body` is a special OpenAPI type that can add additional constraints to a request payload. + 2. `Body(embed=True)` instructs Event Handler to look up inside the payload for a key.

This means Event Handler will look up for a key named `todo`, validate the value against `Todo`, and inject it. + +=== "validating_payload_subset.json" + + ```json hl_lines="3-4 6" + --8<-- "examples/event_handler_rest/src/validating_payload_subset.json" + ``` + +=== "validating_payload_subset_output.json" + + ```json hl_lines="3" + --8<-- "examples/event_handler_rest/src/validating_payload_subset_output.json" + ``` + +#### Validating query strings + +!!! info "We will automatically validate and inject incoming query strings via type annotation." + +We use the `Annotated` type to tell Event Handler that a particular parameter is not only an optional string, but also a query string with constraints. + +In the following example, we use a new `Query` OpenAPI type to add [one out of many possible constraints](#customizing-openapi-parameters), which should read as: + +* `completed` is a query string with a `None` as its default value +* `completed`, when set, should have at minimum 4 characters +* Doesn't match? Event Handler will return a validation error response + + + +=== "validating_query_strings.py" + + ```python hl_lines="8 10 27" + --8<-- "examples/event_handler_rest/src/validating_query_strings.py" + ``` + + 1. If you're not using Python 3.9 or higher, you can install and use [`typing_extensions`](https://pypi.org/project/typing-extensions/){target="_blank" rel="nofollow"} to the same effect + 2. `Query` is a special OpenAPI type that can add constraints to a query string as well as document them + 3. **First time seeing the `Annotated`?**

This special type uses the first argument as the actual type, and subsequent arguments are metadata.

At runtime, static checkers will also see the first argument, but anyone receiving them could inspect them to fetch their metadata. + +=== "skip_validating_query_strings.py" + + If you don't want to validate query strings but simply let Event Handler inject them as parameters, you can omit `Query` type annotation. + + This is merely for your convenience. + + ```python hl_lines="25" + --8<-- "examples/event_handler_rest/src/skip_validating_query_strings.py" + ``` + + 1. `completed` is still the same query string as before, except we simply state it's an string. No `Query` or `Annotated` to validate it. + + + +#### Validating path parameters + +Just like we learned in [query string validation](#validating-query-strings), we can use a new `Path` OpenAPI type to [add constraints](#customizing-openapi-parameters). + +For example, we could validate that `` dynamic path should be no greater than three digits. + +```python hl_lines="8 10 27" title="validating_path.py" +--8<-- "examples/event_handler_rest/src/validating_path.py" +``` + +1. `Path` is a special OpenAPI type that allows us to constrain todo_id to be less than 999. + ### Accessing request details Event Handler integrates with [Event Source Data Classes utilities](../../utilities/data_classes.md){target="_blank"}, and it exposes their respective resolver request details and convenient methods under `app.current_event`. @@ -279,6 +472,30 @@ We provide pre-defined errors for the most popular ones such as HTTP 400, 401, 4 --8<-- "examples/event_handler_rest/src/raising_http_errors.py" ``` +### Enabling SwaggerUI + +!!! note "This feature requires [data validation](#data-validation) feature to be enabled." + +Behind the scenes, the [data validation](#data-validation) feature auto-generates an OpenAPI specification from your routes and type annotations. You can use [Swagger UI](https://swagger.io/tools/swagger-ui/){target="_blank" rel="nofollow"} to visualize and interact with your newly auto-documented API. + +There are some important **caveats** that you should know before enabling it: + +| Caveat | Description | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Swagger UI is **publicly accessible by default** | When using `enable_swagger` method, you can [protect sensitive API endpoints by implementing a custom middleware](#customizing-swagger-ui) using your preferred authorization mechanism. | +| **No micro-functions support** yet | Swagger UI is enabled on a per resolver instance which will limit its accuracy here. | +| You need to expose **new routes** | You'll need to expose the following paths to Lambda: `/swagger`, `/swagger.css`, `/swagger.js`; ignore if you're routing all paths already. | + +```python hl_lines="12-13" title="enabling_swagger.py" +--8<-- "examples/event_handler_rest/src/enabling_swagger.py" +``` + +1. `enable_swagger` creates a route to serve Swagger UI and allows quick customizations.

You can also include middlewares to protect or enhance the overall experience. + +Here's an example of what it looks like by default: + +![Swagger UI picture](../../media/swagger.png) + ### Custom Domain API Mappings When using [Custom Domain API Mappings feature](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-mappings.html){target="_blank"}, you must use **`strip_prefixes`** param in the `APIGatewayRestResolver` constructor. @@ -573,8 +790,8 @@ As a practical example, let's refactor our correlation ID middleware so it accep These are native middlewares that may become native features depending on customer demand. -| Middleware | Purpose | -| ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Middleware | Purpose | +| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | [SchemaValidationMiddleware](/lambda/python/latest/api/event_handler/middlewares/schema_validation.html){target="_blank"} | Validates API request body and response against JSON Schema, using [Validation utility](../../utilities/validation.md){target="_blank"} | #### Being a good citizen @@ -696,6 +913,112 @@ This will enable full tracebacks errors in the response, print request and respo --8<-- "examples/event_handler_rest/src/debug_mode.py" ``` +### OpenAPI + +When you enable [Data Validation](#data-validation), we use a combination of Pydantic Models and [OpenAPI](https://www.openapis.org/){target="_blank"} type annotations to add constraints to your API's parameters. + +In OpenAPI documentation tools like [SwaggerUI](#enabling-swaggerui), these annotations become readable descriptions, offering a self-explanatory API interface. This reduces boilerplate code while improving functionality and enabling auto-documentation. + +???+ note + We don't have support for files, form data, and header parameters at the moment. If you're interested in this, please [open an issue](https://github.com/aws-powertools/powertools-lambda-python/issues/new?assignees=&labels=feature-request%2Ctriage&projects=&template=feature_request.yml&title=Feature+request%3A+TITLE). + +#### Customizing OpenAPI parameters + +Whenever you use OpenAPI parameters to validate [query strings](#validating-query-strings) or [path parameters](#validating-path-parameters), you can enhance validation and OpenAPI documentation by using any of these parameters: + +| Field name | Type | Description | +| --------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alias` | `str` | Alternative name for a field, used when serializing and deserializing data | +| `validation_alias` | `str` | Alternative name for a field during validation (but not serialization) | +| `serialization_alias` | `str` | Alternative name for a field during serialization (but not during validation) | +| `description` | `str` | Human-readable description | +| `gt` | `float` | Greater than. If set, value must be greater than this. Only applicable to numbers | +| `ge` | `float` | Greater than or equal. If set, value must be greater than or equal to this. Only applicable to numbers | +| `lt` | `float` | Less than. If set, value must be less than this. Only applicable to numbers | +| `le` | `float` | Less than or equal. If set, value must be less than or equal to this. Only applicable to numbers | +| `min_length` | `int` | Minimum length for strings | +| `max_length` | `int` | Maximum length for strings | +| `pattern` | `string` | A regular expression that the string must match. | +| `strict` | `bool` | If `True`, strict validation is applied to the field. See [Strict Mode](https://docs.pydantic.dev/latest/concepts/strict_mode/){target"_blank" rel="nofollow"} for details | +| `multiple_of` | `float` | Value must be a multiple of this. Only applicable to numbers | +| `allow_inf_nan` | `bool` | Allow `inf`, `-inf`, `nan`. Only applicable to numbers | +| `max_digits` | `int` | Maximum number of allow digits for strings | +| `decimal_places` | `int` | Maximum number of decimal places allowed for numbers | +| `examples` | `List\[Any\]` | List of examples of the field | +| `deprecated` | `bool` | Marks the field as deprecated | +| `include_in_schema` | `bool` | If `False` the field will not be part of the exported OpenAPI schema | +| `json_schema_extra` | `JsonDict` | Any additional JSON schema data for the schema property | + +#### Customizing API operations + +Customize your API endpoints by adding metadata to endpoint definitions. This provides descriptive documentation for API consumers and gives extra instructions to the framework. + +Here's a breakdown of various customizable fields: + +| Field Name | Type | Description | +| ---------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `summary` | `str` | A concise overview of the main functionality of the endpoint. This brief introduction is usually displayed in autogenerated API documentation and helps consumers quickly understand what the endpoint does. | +| `description` | `str` | A more detailed explanation of the endpoint, which can include information about the operation's behavior, including side effects, error states, and other operational guidelines. | +| `responses` | `Dict[int, Dict[str, Any]]` | A dictionary that maps each HTTP status code to a Response Object as defined by the [OpenAPI Specification](https://swagger.io/specification/#response-object). This allows you to describe expected responses, including default or error messages, and their corresponding schemas for different status codes. | +| `response_description` | `str` | Provides the default textual description of the response sent by the endpoint when the operation is successful. It is intended to give a human-readable understanding of the result. | +| `tags` | `List[str]` | Tags are a way to categorize and group endpoints within the API documentation. They can help organize the operations by resources or other heuristic. | +| `operation_id` | `str` | A unique identifier for the operation, which can be used for referencing this operation in documentation or code. This ID must be unique across all operations described in the API. | +| `include_in_schema` | `bool` | A boolean value that determines whether or not this operation should be included in the OpenAPI schema. Setting it to `False` can hide the endpoint from generated documentation and schema exports, which might be useful for private or experimental endpoints. | + +To implement these customizations, include extra parameters when defining your routes: + +```python hl_lines="11-20" title="customizing_api_operations.py" +--8<-- "examples/event_handler_rest/src/customizing_api_operations.py" +``` + +#### Customizing Swagger UI + +???+note "Customizing the Swagger metadata" + The `enable_swagger` method accepts the same metadata as described at [Customizing OpenAPI metadata](#customizing-openapi-metadata). + +The Swagger UI appears by default at the `/swagger` path, but you can customize this to serve the documentation from another path and specify the source for Swagger UI assets. + +Below is an example configuration for serving Swagger UI from a custom path or CDN, with assets like CSS and JavaScript loading from a chosen CDN base URL. + +=== "customizing_swagger.py" + + ```python hl_lines="10" + --8<-- "examples/event_handler_rest/src/customizing_swagger.py" + ``` + +=== "customizing_swagger_middlewares.py" + + A Middleware can handle tasks such as adding security headers, user authentication, or other request processing for serving the Swagger UI. + + ```python hl_lines="7 13-18 21" + --8<-- "examples/event_handler_rest/src/customizing_swagger_middlewares.py" + ``` + +#### Customizing OpenAPI metadata + +Defining and customizing OpenAPI metadata gives detailed, top-level information about your API. Here's the method to set and tailor this metadata: + +| Field Name | Type | Description | +| ------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | `str` | The title for your API. It should be a concise, specific name that can be used to identify the API in documentation or listings. | +| `version` | `str` | The version of the API you are documenting. This could reflect the release iteration of the API and helps clients understand the evolution of the API. | +| `openapi_version` | `str` | Specifies the version of the OpenAPI Specification on which your API is based. For most contemporary APIs, the default value would be `3.0.0` or higher. | +| `summary` | `str` | A short and informative summary that can provide an overview of what the API does. This can be the same as or different from the title but should add context or information. | +| `description` | `str` | A verbose description that can include Markdown formatting, providing a full explanation of the API's purpose, functionalities, and general usage instructions. | +| `tags` | `List[str]` | A collection of tags that categorize endpoints for better organization and navigation within the documentation. This can group endpoints by their functionality or other criteria. | +| `servers` | `List[Server]` | An array of Server objects, which specify the URL to the server and a description for its environment (production, staging, development, etc.), providing connectivity information. | +| `terms_of_service` | `str` | A URL that points to the terms of service for your API. This could provide legal information and user responsibilities related to the usage of the API. | +| `contact` | `Contact` | A Contact object containing contact details of the organization or individuals maintaining the API. This may include fields such as name, URL, and email. | +| `license_info` | `License` | A License object providing the license details for the API, typically including the name of the license and the URL to the full license text. | + +Include extra parameters when exporting your OpenAPI specification to apply these customizations: + +=== "customizing_api_metadata.py" + + ```python hl_lines="25-31" + --8<-- "examples/event_handler_rest/src/customizing_api_metadata.py" + ``` + ### Custom serializer You can instruct event handler to use a custom serializer to best suit your needs, for example take into account Enums when serializing. diff --git a/docs/media/swagger.png b/docs/media/swagger.png new file mode 100644 index 0000000000000000000000000000000000000000..3db7786886b08e4909f2f73e2e9f4ecc5f16c359 GIT binary patch literal 54463 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

XMDc7Vh@+(d=~5FzW9bXXyu8VC>x=wN zh8SMCVxQ)+T|9roWF;}!(1DYe4Fl@)9v;LI9DXHmZYm9qhq$9}%6bi}xO}V0_w4X)u5IyNJJa zbmvQ)Y+_Abm_EY4&8z|O;sB~*b%RX20u}ci>=!V3?mi}BRLE-b`MYOMl4R6)3R{79 z{2{ik^Qa8OTIxpAH;5|;&GV9tYcNiVEpJ76f{rlw-*@vYd1|Nux%)cyG?}R{0nAlb zaH3EK=Yv3k?uf!ncbsLsP3#4*;5a4hUS)n*`TUcsNNP0{`y>*Mbw zf*1hHurcrsa77%5+pfMuAjP2Ey8ucFP*VdLNPQ^mJ#L?z>)czGR0iXy0}&R8;^aO) zIEGI032mxcXclJ4Q*Pfe1Ho)AL`@RWFT}3WEg1<}Z*vn68p~0p8tw_%ZlAg%}Jc1kBY8QYYjCj5%i;h9$qeEWw_e--?KI-jU$d3sBL3EHzL8 zY(R3?a}<0b6Yx?PN-vH(0LgSsnXpn^P>2m?kwPs5 z0Uk%Wj<%;r12H%ro=Q02QC4;V%&T6#mH@qbr~vdjCEA-oN76p#W8!3q@O2{ZZA*R# zGTo~;T#F-H+ko{PHX_>=x0ih37!q-q3v1L?H&B;QLgAPSxtLz|c*P$$XO1?&kU_}?%p?@alII(jy*J$DMc$3#~5%FL)oAHH@?P#2U7N7|J z0bdBHsA(XqL~aAZfkYUp5Kn^ztc{784`umD~sLE2xFia3>s+a`qPY)e-+eLW%isRofnOq!r`B+Z)r#uHi zs4FWWq!MhT03N+1cGW?6ch~R=1!u?^q`v@h^*#uwq5T!X)UdW$$NdTnV(E09AY^zE z3y(69f|{@;4QP{o#(t8-y*{KE!6(x&QKQzi>Oid)O0CCegLlK?y>zC6$;->N*k)AB zvGEDkcHiU!p07~^Ek&%4Py?gWtesU7k+#M~kh8IW2rYT`LEHJqZSOn)AV%INPQ`Jp z-JsL)vBu0ZunV}LX8rfJHJ9vwb;<+7b;*~j&G(R?m9wHvW^MvJOC6&Yus1(&8>yNr zcIliK1>WCCs~1g{DS&0JVcY?{<8OwWp{^P?fVXj~)^m8#DOgOlQ~p^+3rOvuDand4sh z_LfPGXlU(n$4h_^S2%|qw`Imb&dBaJj<6V(1Ew)P5b#uCF;-V3&KN62U@I(jcrqP; z^tZSm8UvYnS^4!UloBOQ0L57rA5$PnDLp zi|1g4boof1dlF#XuGECX3jnhai3p4F>O?7BH9F6vc_7O3`5F(@F_JZkN zi!Uv!{t-WF0CZC-S#S@tT@dQhAj5SG4if<9(1-ff1~V+5m3sFN4GuqWpVpzQ5IpLQ zLcVYVq~Uv%43N_k43eZR&JEC1asUpU8Uu9*eb@4PRS&^%i{FD3EM)A;1RQiPR>C%j z!J}gHVhpn&*2`l{wb*+kl=DqvPc0qY(mi(B+jisp)3(}6I++1rM9%>vS4FL&L>lB; zTw+wqUR&%wsmKn=2Zs7kt-CX_Qu93Fj5-CjA7>Ihm^?lW;kZ9tgKV2J)cn5b zi`l{L(CNxt0K$O(qXNu-S7!F#!Mgt`pS%g`cK!>T9!lGRI0=%vX8+e|du^QLd-7Fi zCg!cHjiL&-X9Qz1B(=#C1F9Qa{U6MI@c#yLa@1K^dD^^a;)(a~f9ab~x+e<5SCWuL zZVmY}") +def get_todo_title(todo_id: int) -> str: + todo = requests.get(f"https://jsonplaceholder.typicode.com/todos/{todo_id}") + todo.raise_for_status() + + return todo.json()["title"] + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) + + +if __name__ == "__main__": + print( + app.get_openapi_json_schema( + title="TODO's API", + version="1.21.3", + summary="API to manage TODOs", + description="This API implements all the CRUD operations for the TODO app", + tags=["todos"], + servers=[Server(url="https://stg.example.org/orders", description="Staging server")], + contact=Contact(name="John Smith", email="john@smith.com"), + ), + ) diff --git a/examples/event_handler_rest/src/customizing_api_operations.py b/examples/event_handler_rest/src/customizing_api_operations.py new file mode 100644 index 0000000000..e455fc7dad --- /dev/null +++ b/examples/event_handler_rest/src/customizing_api_operations.py @@ -0,0 +1,30 @@ +import requests + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayRestResolver(enable_validation=True) + + +@app.get( + "/todos/", + summary="Retrieves a todo item", + description="Loads a todo item identified by the `todo_id`", + response_description="The todo object", + responses={ + 200: {"description": "Todo item found"}, + 404: { + "description": "Item not found", + }, + }, + tags=["Todos"], +) +def get_todo_title(todo_id: int) -> str: + todo = requests.get(f"https://jsonplaceholder.typicode.com/todos/{todo_id}") + todo.raise_for_status() + + return todo.json()["title"] + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/customizing_swagger.py b/examples/event_handler_rest/src/customizing_swagger.py new file mode 100644 index 0000000000..4903ff2544 --- /dev/null +++ b/examples/event_handler_rest/src/customizing_swagger.py @@ -0,0 +1,29 @@ +from typing import List + +import requests +from pydantic import BaseModel, EmailStr, Field + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayRestResolver(enable_validation=True) +app.enable_swagger(path="/_swagger", swagger_base_url="https://cdn.example.com/path/to/assets/") + + +class Todo(BaseModel): + userId: int + id_: int = Field(alias="id") + title: str + completed: bool + + +@app.get("/todos") +def get_todos_by_email(email: EmailStr) -> List[Todo]: + todos = requests.get(f"https://jsonplaceholder.typicode.com/todos?email={email}") + todos.raise_for_status() + + return todos.json() + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/customizing_swagger_middlewares.py b/examples/event_handler_rest/src/customizing_swagger_middlewares.py new file mode 100644 index 0000000000..49822fecef --- /dev/null +++ b/examples/event_handler_rest/src/customizing_swagger_middlewares.py @@ -0,0 +1,40 @@ +from typing import List + +import requests +from pydantic import BaseModel, EmailStr, Field + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response +from aws_lambda_powertools.event_handler.middlewares import NextMiddleware +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayRestResolver(enable_validation=True) + + +def swagger_middleware(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response: + is_authenticated = ... + if not is_authenticated: + return Response(status_code=400, body="Unauthorized") + + return next_middleware(app) + + +app.enable_swagger(middlewares=[swagger_middleware]) + + +class Todo(BaseModel): + userId: int + id_: int = Field(alias="id") + title: str + completed: bool + + +@app.get("/todos") +def get_todos_by_email(email: EmailStr) -> List[Todo]: + todos = requests.get(f"https://jsonplaceholder.typicode.com/todos?email={email}") + todos.raise_for_status() + + return todos.json() + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/data_validation.json b/examples/event_handler_rest/src/data_validation.json new file mode 100644 index 0000000000..f5814ccaa2 --- /dev/null +++ b/examples/event_handler_rest/src/data_validation.json @@ -0,0 +1,36 @@ +{ + "version": "1.0", + "resource": "/todos/1", + "path": "/todos/1", + "httpMethod": "GET", + "headers": { + "Origin": "https://aws.amazon.com" + }, + "multiValueHeaders": {}, + "queryStringParameters": {}, + "multiValueQueryStringParameters": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "id", + "authorizer": { + "claims": null, + "scopes": null + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "extendedRequestId": "request-id", + "httpMethod": "GET", + "path": "/todos/1", + "protocol": "HTTP/1.1", + "requestId": "id=", + "requestTime": "04/Mar/2020:19:15:17 +0000", + "requestTimeEpoch": 1583349317135, + "resourceId": null, + "resourcePath": "/todos/1", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "body": "", + "isBase64Encoded": false +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/data_validation.py b/examples/event_handler_rest/src/data_validation.py new file mode 100644 index 0000000000..1daa9fb217 --- /dev/null +++ b/examples/event_handler_rest/src/data_validation.py @@ -0,0 +1,35 @@ +from typing import Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) # (1)! + + +class Todo(BaseModel): # (2)! + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.get("/todos/") # (3)! +@tracer.capture_method +def get_todo_by_id(todo_id: int) -> Todo: # (4)! + todo = requests.get(f"https://jsonplaceholder.typicode.com/todos/{todo_id}") + todo.raise_for_status() + + return todo.json() # (5)! + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/data_validation_error.json b/examples/event_handler_rest/src/data_validation_error.json new file mode 100644 index 0000000000..6fc2636ad9 --- /dev/null +++ b/examples/event_handler_rest/src/data_validation_error.json @@ -0,0 +1,42 @@ +{ + "version": "2.0", + "routeKey": "$default", + "rawPath": "/todos/apples", + "rawQueryString": "", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "header1": "value1", + "header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "value1,value2", + "parameter2": "value" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "GET", + "path": "/todos/apples", + "protocol": "HTTP/1.1", + "sourceIp": "192.0.2.1", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "pathParameters": {}, + "isBase64Encoded": false, + "stageVariables": { + "stageVariable1": "value1", + "stageVariable2": "value2" + } +} diff --git a/examples/event_handler_rest/src/data_validation_error_unsanitized_output.json b/examples/event_handler_rest/src/data_validation_error_unsanitized_output.json new file mode 100644 index 0000000000..46d22c00ee --- /dev/null +++ b/examples/event_handler_rest/src/data_validation_error_unsanitized_output.json @@ -0,0 +1,9 @@ +{ + "statusCode": 422, + "body": "{\"statusCode\": 422, \"detail\": [{\"type\": \"int_parsing\", \"loc\": [\"path\", \"todo_id\"]}]}", + "isBase64Encoded": false, + "headers": { + "Content-Type": "application/json" + }, + "cookies": [] +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/data_validation_output.json b/examples/event_handler_rest/src/data_validation_output.json new file mode 100644 index 0000000000..ec078c8707 --- /dev/null +++ b/examples/event_handler_rest/src/data_validation_output.json @@ -0,0 +1,10 @@ +{ + "statusCode": 200, + "body": "Hello world", + "isBase64Encoded": false, + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ] + } +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/data_validation_sanitized_error.py b/examples/event_handler_rest/src/data_validation_sanitized_error.py new file mode 100644 index 0000000000..71849938f4 --- /dev/null +++ b/examples/event_handler_rest/src/data_validation_sanitized_error.py @@ -0,0 +1,46 @@ +from typing import Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response, content_types +from aws_lambda_powertools.event_handler.openapi.exceptions import RequestValidationError +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) + + +class Todo(BaseModel): + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.exception_handler(RequestValidationError) # (1)! +def handle_validation_error(ex: RequestValidationError): + logger.error("Request failed validation", path=app.current_event.path, errors=ex.errors()) + + return Response( + status_code=422, + content_type=content_types.APPLICATION_JSON, + body="Invalid data", + ) + + +@app.post("/todos") +def create_todo(todo: Todo) -> int: + response = requests.post("https://jsonplaceholder.typicode.com/todos", json=todo.dict(by_alias=True)) + response.raise_for_status() + + return response.json()["id"] + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/data_validation_sanitized_error_output.json b/examples/event_handler_rest/src/data_validation_sanitized_error_output.json new file mode 100644 index 0000000000..aa6ab7e0d5 --- /dev/null +++ b/examples/event_handler_rest/src/data_validation_sanitized_error_output.json @@ -0,0 +1,9 @@ +{ + "statusCode": 422, + "body": "Invalid data", + "isBase64Encoded": false, + "headers": { + "Content-Type": "application/json" + }, + "cookies": [] +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/enabling_swagger.py b/examples/event_handler_rest/src/enabling_swagger.py new file mode 100644 index 0000000000..b624af77d3 --- /dev/null +++ b/examples/event_handler_rest/src/enabling_swagger.py @@ -0,0 +1,40 @@ +from typing import List + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) +app.enable_swagger(path="/swagger") # (1)! + + +class Todo(BaseModel): + userId: int + id_: int = Field(alias="id") + title: str + completed: bool + + +@app.post("/todos") +def create_todo(todo: Todo) -> str: + response = requests.post("https://jsonplaceholder.typicode.com/todos", json=todo.dict(by_alias=True)) + response.raise_for_status() + + return response.json()["id"] + + +@app.get("/todos") +def get_todos() -> List[Todo]: + todo = requests.get("https://jsonplaceholder.typicode.com/todos") + todo.raise_for_status() + + return todo.json() + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/skip_validating_query_strings.py b/examples/event_handler_rest/src/skip_validating_query_strings.py new file mode 100644 index 0000000000..882769239a --- /dev/null +++ b/examples/event_handler_rest/src/skip_validating_query_strings.py @@ -0,0 +1,40 @@ +from typing import List, Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) + + +class Todo(BaseModel): + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.get("/todos") +@tracer.capture_method +def get_todos(completed: Optional[str] = None) -> List[Todo]: # (1)! + url = "https://jsonplaceholder.typicode.com/todos" + + if completed is not None: + url = f"{url}/?completed={completed}" + + todo = requests.get(url) + todo.raise_for_status() + + return todo.json() + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/validating_path.py b/examples/event_handler_rest/src/validating_path.py new file mode 100644 index 0000000000..e892e1c859 --- /dev/null +++ b/examples/event_handler_rest/src/validating_path.py @@ -0,0 +1,37 @@ +from typing import Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.event_handler.openapi.params import Path +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import Annotated +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) + + +class Todo(BaseModel): + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.get("/todos/") +@tracer.capture_method +def get_todo_by_id(todo_id: Annotated[int, Path(lt=999)]) -> Todo: # (1)! + todo = requests.get(f"https://jsonplaceholder.typicode.com/todos/{todo_id}") + todo.raise_for_status() + + return todo.json() + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/validating_payload_subset.json b/examples/event_handler_rest/src/validating_payload_subset.json new file mode 100644 index 0000000000..b786e9287b --- /dev/null +++ b/examples/event_handler_rest/src/validating_payload_subset.json @@ -0,0 +1,36 @@ +{ + "version": "1.0", + "body": "{ \"todo\": {\"title\": \"foo\", \"userId\": \"1\", \"completed\": false } }", + "resource": "/todos", + "path": "/todos", + "httpMethod": "POST", + "headers": { + "Origin": "https://aws.amazon.com" + }, + "multiValueHeaders": {}, + "queryStringParameters": {}, + "multiValueQueryStringParameters": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "id", + "authorizer": { + "claims": null, + "scopes": null + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "extendedRequestId": "request-id", + "httpMethod": "POST", + "path": "/todos", + "protocol": "HTTP/1.1", + "requestId": "id=", + "requestTime": "04/Mar/2020:19:15:17 +0000", + "requestTimeEpoch": 1583349317135, + "resourceId": null, + "resourcePath": "/todos", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "isBase64Encoded": false +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/validating_payload_subset.py b/examples/event_handler_rest/src/validating_payload_subset.py new file mode 100644 index 0000000000..ac4ee60385 --- /dev/null +++ b/examples/event_handler_rest/src/validating_payload_subset.py @@ -0,0 +1,30 @@ +from typing import Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.event_handler.openapi.params import Body # (1)! +from aws_lambda_powertools.shared.types import Annotated +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayRestResolver(enable_validation=True) + + +class Todo(BaseModel): + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.post("/todos") +def create_todo(todo: Annotated[Todo, Body(embed=True)]) -> int: # (2)! + response = requests.post("https://jsonplaceholder.typicode.com/todos", json=todo.dict(by_alias=True)) + response.raise_for_status() + + return response.json()["id"] + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/validating_payload_subset_output.json b/examples/event_handler_rest/src/validating_payload_subset_output.json new file mode 100644 index 0000000000..754e3a6c12 --- /dev/null +++ b/examples/event_handler_rest/src/validating_payload_subset_output.json @@ -0,0 +1,10 @@ +{ + "statusCode": 200, + "body": "2008822", + "isBase64Encoded": false, + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ] + } +} diff --git a/examples/event_handler_rest/src/validating_payloads.json b/examples/event_handler_rest/src/validating_payloads.json new file mode 100644 index 0000000000..125405e0cf --- /dev/null +++ b/examples/event_handler_rest/src/validating_payloads.json @@ -0,0 +1,36 @@ +{ + "version": "1.0", + "body": "{\"title\": \"foo\", \"userId\": \"1\", \"completed\": false}", + "resource": "/todos", + "path": "/todos", + "httpMethod": "POST", + "headers": { + "Origin": "https://aws.amazon.com" + }, + "multiValueHeaders": {}, + "queryStringParameters": {}, + "multiValueQueryStringParameters": {}, + "requestContext": { + "accountId": "123456789012", + "apiId": "id", + "authorizer": { + "claims": null, + "scopes": null + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "extendedRequestId": "request-id", + "httpMethod": "POST", + "path": "/todos", + "protocol": "HTTP/1.1", + "requestId": "id=", + "requestTime": "04/Mar/2020:19:15:17 +0000", + "requestTimeEpoch": 1583349317135, + "resourceId": null, + "resourcePath": "/todos", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "isBase64Encoded": false +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/validating_payloads.py b/examples/event_handler_rest/src/validating_payloads.py new file mode 100644 index 0000000000..945cefd808 --- /dev/null +++ b/examples/event_handler_rest/src/validating_payloads.py @@ -0,0 +1,43 @@ +from typing import List, Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) # (1)! + + +class Todo(BaseModel): # (2)! + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.post("/todos") +def create_todo(todo: Todo) -> str: # (3)! + response = requests.post("https://jsonplaceholder.typicode.com/todos", json=todo.dict(by_alias=True)) + response.raise_for_status() + + return response.json()["id"] # (4)! + + +@app.get("/todos") +@tracer.capture_method +def get_todos() -> List[Todo]: + todo = requests.get("https://jsonplaceholder.typicode.com/todos") + todo.raise_for_status() + + return todo.json() # (5)! + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/validating_payloads_output.json b/examples/event_handler_rest/src/validating_payloads_output.json new file mode 100644 index 0000000000..9d72764c3c --- /dev/null +++ b/examples/event_handler_rest/src/validating_payloads_output.json @@ -0,0 +1,10 @@ +{ + "statusCode": 200, + "body": "2008821", + "isBase64Encoded": false, + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ] + } +} \ No newline at end of file diff --git a/examples/event_handler_rest/src/validating_query_strings.py b/examples/event_handler_rest/src/validating_query_strings.py new file mode 100644 index 0000000000..21d34dbd25 --- /dev/null +++ b/examples/event_handler_rest/src/validating_query_strings.py @@ -0,0 +1,42 @@ +from typing import List, Optional + +import requests +from pydantic import BaseModel, Field + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.event_handler.openapi.params import Query # (2)! +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import Annotated # (1)! +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver(enable_validation=True) + + +class Todo(BaseModel): + userId: int + id_: Optional[int] = Field(alias="id", default=None) + title: str + completed: bool + + +@app.get("/todos") +@tracer.capture_method +def get_todos(completed: Annotated[Optional[str], Query(min_length=4)] = None) -> List[Todo]: # (3)! + url = "https://jsonplaceholder.typicode.com/todos" + + if completed is not None: + url = f"{url}/?completed={completed}" + + todo = requests.get(url) + todo.raise_for_status() + + return todo.json() + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context)