From 7f9e6d002a24126a32b9cdf05b3a569e95f98bbe Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Fri, 9 Jan 2026 16:53:05 +0000 Subject: [PATCH] docs: add openapi docs --- .../event_handler/openapi/merge.py | 358 +++++++------- docs/core/event_handler/api_gateway.md | 149 +----- docs/core/event_handler/openapi.md | 450 ++++++++++++++++++ .../src/openapi_merge_conflict.py | 9 + .../src/openapi_merge_full_config.py | 18 + .../src/openapi_merge_multiple_patterns.py | 9 + .../src/openapi_merge_resolver.py | 18 + .../src/openapi_merge_shared_discovery.py | 13 + .../src/openapi_merge_shared_orders_routes.py | 7 + .../src/openapi_merge_shared_resolver.py | 4 + .../src/openapi_merge_shared_users_routes.py | 12 + .../src/openapi_merge_standalone.py | 23 + .../src/openapi_merge_with_exclusions.py | 10 + mkdocs.yml | 1 + .../merge_handlers/shared/__init__.py | 0 .../shared/categories_routes.py | 11 + .../merge_handlers/shared/products_routes.py | 17 + .../merge_handlers/shared/resolver.py | 5 + .../_pydantic/test_openapi_merge.py | 33 ++ .../openapi/test_openapi_merge.py | 8 +- 20 files changed, 815 insertions(+), 340 deletions(-) create mode 100644 docs/core/event_handler/openapi.md create mode 100644 examples/event_handler_rest/src/openapi_merge_conflict.py create mode 100644 examples/event_handler_rest/src/openapi_merge_full_config.py create mode 100644 examples/event_handler_rest/src/openapi_merge_multiple_patterns.py create mode 100644 examples/event_handler_rest/src/openapi_merge_resolver.py create mode 100644 examples/event_handler_rest/src/openapi_merge_shared_discovery.py create mode 100644 examples/event_handler_rest/src/openapi_merge_shared_orders_routes.py create mode 100644 examples/event_handler_rest/src/openapi_merge_shared_resolver.py create mode 100644 examples/event_handler_rest/src/openapi_merge_shared_users_routes.py create mode 100644 examples/event_handler_rest/src/openapi_merge_standalone.py create mode 100644 examples/event_handler_rest/src/openapi_merge_with_exclusions.py create mode 100644 tests/functional/event_handler/_pydantic/merge_handlers/shared/__init__.py create mode 100644 tests/functional/event_handler/_pydantic/merge_handlers/shared/categories_routes.py create mode 100644 tests/functional/event_handler/_pydantic/merge_handlers/shared/products_routes.py create mode 100644 tests/functional/event_handler/_pydantic/merge_handlers/shared/resolver.py diff --git a/aws_lambda_powertools/event_handler/openapi/merge.py b/aws_lambda_powertools/event_handler/openapi/merge.py index 38b80914df3..2c51686a2b1 100644 --- a/aws_lambda_powertools/event_handler/openapi/merge.py +++ b/aws_lambda_powertools/event_handler/openapi/merge.py @@ -53,9 +53,9 @@ def _is_resolver_call(node: ast.expr) -> bool: func = node.func if isinstance(func, ast.Name) and func.id in RESOLVER_CLASSES: return True - if isinstance(func, ast.Attribute) and func.attr in RESOLVER_CLASSES: # pragma: no cover + if isinstance(func, ast.Attribute) and func.attr in RESOLVER_CLASSES: return True - return False # pragma: no cover + return False def _file_has_resolver(file_path: Path, resolver_name: str) -> bool: @@ -75,6 +75,65 @@ def _file_has_resolver(file_path: Path, resolver_name: str) -> bool: return False +def _file_imports_resolver(file_path: Path, resolver_file: Path, resolver_name: str, root: Path) -> bool: + """Check if a Python file imports the resolver from the resolver file.""" + try: + source = file_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(file_path)) + except (SyntaxError, UnicodeDecodeError): + return False + + # Get the module path of the resolver file relative to root + # e.g., "service/handlers/utils/rest_api_resolver.py" -> "service.handlers.utils.rest_api_resolver" + resolver_relative = resolver_file.relative_to(root).with_suffix("") + resolver_module = ".".join(resolver_relative.parts) + + for node in ast.walk(tree): + # Check "from X import app" or "from X import app as something" + if isinstance(node, ast.ImportFrom) and node.module: + for alias in node.names: + if alias.name == resolver_name: + # Check if the import module matches the resolver module + if node.module == resolver_module: + return True + return False + + +def _find_dependent_files( + search_path: Path, + resolver_file: Path, + resolver_name: str, + exclude: list[str], + project_root: Path, +) -> list[Path]: + """Find all Python files that import the resolver. + + Parameters + ---------- + search_path : Path + Directory to search for dependent files. + resolver_file : Path + The resolver file that dependents import from. + resolver_name : str + Variable name of the resolver. + exclude : list[str] + Patterns to exclude. + project_root : Path + Root directory for resolving Python imports. + """ + dependent_files: list[Path] = [] + + for file_path in search_path.rglob("*.py"): + if file_path == resolver_file: + continue + if _is_excluded(file_path, search_path, exclude): + continue + if _file_imports_resolver(file_path, resolver_file, resolver_name, project_root): + dependent_files.append(file_path) + + return sorted(dependent_files) + + def _is_excluded(file_path: Path, root: Path, exclude_patterns: list[str]) -> bool: """Check if a file matches any exclusion pattern.""" relative_str = str(file_path.relative_to(root)) @@ -84,12 +143,11 @@ def _is_excluded(file_path: Path, root: Path, exclude_patterns: list[str]) -> bo sub_pattern = pattern[3:] if fnmatch.fnmatch(relative_str, pattern) or fnmatch.fnmatch(file_path.name, sub_pattern): return True - # Check directory parts - remove trailing glob patterns clean_pattern = sub_pattern.replace("/**", "").replace("/*", "") for part in file_path.relative_to(root).parts: - if fnmatch.fnmatch(part, clean_pattern): # pragma: no cover + if fnmatch.fnmatch(part, clean_pattern): return True - elif fnmatch.fnmatch(relative_str, pattern) or fnmatch.fnmatch(file_path.name, pattern): # pragma: no cover + elif fnmatch.fnmatch(relative_str, pattern) or fnmatch.fnmatch(file_path.name, pattern): return True return False @@ -99,7 +157,7 @@ def _get_glob_pattern(pat: str, recursive: bool) -> str: if recursive and not pat.startswith("**/"): return f"**/{pat}" if not recursive and pat.startswith("**/"): - return pat[3:] # Strip **/ prefix + return pat[3:] return pat @@ -131,133 +189,81 @@ def _discover_resolver_files( return sorted(found_files) -def _load_resolver(file_path: Path, resolver_name: str) -> Any: - """Load a resolver instance from a Python file.""" - file_path = Path(file_path).resolve() - module_name = f"_powertools_openapi_merge_{file_path.stem}_{id(file_path)}" - +def _load_module(file_path: Path, module_name: str) -> Any: + """Load a Python module from file.""" spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None or spec.loader is None: # pragma: no cover + if spec is None or spec.loader is None: raise ImportError(f"Cannot load module from {file_path}") module = importlib.util.module_from_spec(spec) - module_dir = str(file_path.parent) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _load_resolver_with_dependencies( + file_path: Path, + resolver_name: str, + dependent_files: list[Path], + root: Path, +) -> Any: + """Load a resolver instance, first loading all dependent files that register routes.""" + file_path = Path(file_path).resolve() + + # Add root to sys.path if not already there + root_str = str(root) original_path = sys.path.copy() try: - if module_dir not in sys.path: - sys.path.insert(0, module_dir) - sys.modules[module_name] = module - spec.loader.exec_module(module) + if root_str not in sys.path: + sys.path.insert(0, root_str) + + # First, load all dependent files (they will import the resolver and register routes) + for dep_file in dependent_files: + dep_module_name = f"_powertools_dep_{dep_file.stem}_{id(dep_file)}" + try: + _load_module(dep_file, dep_module_name) + logger.debug(f"Loaded dependent file: {dep_file}") + except Exception as e: + logger.warning(f"Failed to load dependent file {dep_file}: {e}") + + # Now get the resolver - it should already be loaded by the dependent files + # Try to get it from the module that was loaded by dependents + resolver_relative = file_path.relative_to(root).with_suffix("") + resolver_module_name = ".".join(resolver_relative.parts) + + if resolver_module_name in sys.modules: + module = sys.modules[resolver_module_name] + else: + # Fallback: load the resolver file directly + module_name = f"_powertools_openapi_merge_{file_path.stem}_{id(file_path)}" + module = _load_module(file_path, module_name) if not hasattr(module, resolver_name): raise AttributeError(f"Resolver '{resolver_name}' not found in {file_path}.") return getattr(module, resolver_name) finally: sys.path = original_path - sys.modules.pop(module_name, None) def _model_to_dict(obj: Any) -> Any: """Convert Pydantic model to dict if needed.""" if hasattr(obj, "model_dump"): return obj.model_dump(by_alias=True, exclude_none=True) - return obj # pragma: no cover + return obj class OpenAPIMerge: """ Discover and merge OpenAPI schemas from multiple Lambda handlers. - This class is designed for micro-functions architectures where you have multiple - Lambda functions, each with its own resolver, and need to generate a unified - OpenAPI specification. It's particularly useful for: - - - CI/CD pipelines to generate and publish unified API documentation - - Build-time schema generation for API Gateway imports - - Creating a dedicated Lambda that serves the consolidated OpenAPI spec - - The class uses AST analysis to detect resolver instances without importing modules, - making discovery fast and safe. + This class supports two patterns: + 1. Standard pattern: Each handler file defines its own resolver with routes + 2. Shared resolver pattern: A central resolver file is imported by multiple handler files + that register routes on it - Parameters - ---------- - title : str - The title of the unified API. - version : str - The version of the API (e.g., "1.0.0"). - openapi_version : str, default "3.1.0" - The OpenAPI specification version. - summary : str, optional - A short summary of the API. - description : str, optional - A detailed description of the API. - tags : list[Tag | str], optional - Tags for API documentation organization. - servers : list[Server], optional - Server objects for API connectivity information. - terms_of_service : str, optional - URL to the Terms of Service. - contact : Contact, optional - Contact information for the API. - license_info : License, optional - License information for the API. - security_schemes : dict[str, SecurityScheme], optional - Security scheme definitions. - security : list[dict[str, list[str]]], optional - Global security requirements. - external_documentation : ExternalDocumentation, optional - Link to external documentation. - openapi_extensions : dict[str, Any], optional - OpenAPI specification extensions (x-* fields). - on_conflict : Literal["warn", "error", "first", "last"], default "warn" - Strategy when the same path+method is defined in multiple handlers: - - "warn": Log warning and keep first definition - - "error": Raise OpenAPIMergeError - - "first": Silently keep first definition - - "last": Use last definition (override) - - Example - ------- - **CI/CD Pipeline - Generate unified schema at build time:** - - >>> from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge - >>> - >>> merge = OpenAPIMerge( - ... title="My Unified API", - ... version="1.0.0", - ... description="Consolidated API from multiple Lambda functions", - ... ) - >>> merge.discover( - ... path="./src/functions", - ... pattern="**/handler.py", - ... exclude=["**/tests/**"], - ... ) - >>> schema_json = merge.get_openapi_json_schema() - >>> - >>> # Write to file for API Gateway import or documentation - >>> with open("openapi.json", "w") as f: - ... f.write(schema_json) - - **Dedicated OpenAPI Lambda - Serve unified spec at runtime:** - - >>> from aws_lambda_powertools.event_handler import APIGatewayRestResolver - >>> - >>> app = APIGatewayRestResolver() - >>> app.configure_openapi_merge( - ... path="./functions", - ... pattern="**/handler.py", - ... title="My API", - ... version="1.0.0", - ... ) - >>> app.enable_swagger(path="/docs") # Swagger UI with merged schema - >>> - >>> def handler(event, context): - ... return app.resolve(event, context) - - See Also - -------- - OpenAPIMergeError : Exception raised on merge conflicts when on_conflict="error" + For the shared resolver pattern, this class automatically discovers files that import + the resolver and loads them before extracting the schema, ensuring all routes are registered. """ def __init__( @@ -297,9 +303,12 @@ def __init__( ) self._schemas: list[dict[str, Any]] = [] self._discovered_files: list[Path] = [] + self._dependent_files: dict[Path, list[Path]] = {} self._resolver_name: str = "app" self._on_conflict = on_conflict self._cached_schema: dict[str, Any] | None = None + self._root: Path | None = None + self._exclude: list[str] = [] def discover( self, @@ -308,45 +317,41 @@ def discover( exclude: list[str] | None = None, resolver_name: str = "app", recursive: bool = False, + project_root: str | Path | None = None, ) -> list[Path]: - """ - Discover resolver files in the specified path using glob patterns. - - This method scans the directory tree for Python files matching the pattern, - then uses AST analysis to identify files containing resolver instances. + """Discover resolver files and their dependent handler files. Parameters ---------- path : str | Path - Root directory to search for handler files. - pattern : str | list[str], default "handler.py" + Directory to search for resolver files. + pattern : str | list[str] Glob pattern(s) to match handler files. - exclude : list[str], optional - Patterns to exclude. Defaults to ["**/tests/**", "**/__pycache__/**", "**/.venv/**"]. - resolver_name : str, default "app" - Variable name of the resolver instance in handler files. - recursive : bool, default False - Whether to search recursively in subdirectories. - - Returns - ------- - list[Path] - List of discovered files containing resolver instances. - - Example - ------- - >>> merge = OpenAPIMerge(title="API", version="1.0.0") - >>> files = merge.discover( - ... path="./src", - ... pattern=["handler.py", "api.py"], - ... exclude=["**/tests/**", "**/legacy/**"], - ... recursive=True, - ... ) - >>> print(f"Found {len(files)} handlers") + exclude : list[str] | None + Patterns to exclude. + resolver_name : str + Variable name of the resolver instance. + recursive : bool + Whether to search recursively. + project_root : str | Path | None + Root directory for resolving Python imports. If None, uses current working directory. + This is needed when handlers import the resolver using absolute imports like + 'from service.handlers.utils.resolver import app'. """ exclude = exclude or ["**/tests/**", "**/__pycache__/**", "**/.venv/**"] + self._exclude = exclude self._resolver_name = resolver_name + self._search_path = Path(path).resolve() + self._root = Path(project_root).resolve() if project_root else self._search_path + self._discovered_files = _discover_resolver_files(path, pattern, exclude, resolver_name, recursive) + + # For each resolver file, find files that import it (search within path, resolve imports with project_root) + for resolver_file in self._discovered_files: + dependent = _find_dependent_files(self._search_path, resolver_file, resolver_name, exclude, self._root) + self._dependent_files[resolver_file] = dependent + logger.debug(f"Found {len(dependent)} dependent files for {resolver_file}") + return self._discovered_files def add_file(self, file_path: str | Path, resolver_name: str | None = None) -> None: @@ -369,87 +374,59 @@ def add_schema(self, schema: dict[str, Any]) -> None: """ self._schemas.append(_model_to_dict(schema)) - def get_openapi_schema(self) -> dict[str, Any]: - """ - Generate the merged OpenAPI schema as a dictionary. - - Loads all discovered resolver files, extracts their OpenAPI schemas, - and merges them into a single unified specification. - - The schema is cached after the first generation for performance. + @property + def discovered_files(self) -> list[Path]: + """Get the list of discovered resolver files.""" + return self._discovered_files.copy() - Returns - ------- - dict[str, Any] - The merged OpenAPI schema. + @property + def dependent_files(self) -> dict[Path, list[Path]]: + """Get the mapping of resolver files to their dependent handler files.""" + return {k: v.copy() for k, v in self._dependent_files.items()} - Raises - ------ - OpenAPIMergeError - If on_conflict="error" and duplicate path+method combinations are found. - """ + def get_openapi_schema(self) -> dict[str, Any]: + """Generate the merged OpenAPI schema.""" if self._cached_schema is not None: return self._cached_schema - # Load schemas from discovered files for file_path in self._discovered_files: try: - resolver = _load_resolver(file_path, self._resolver_name) + dependent = self._dependent_files.get(file_path, []) + root = self._root or file_path.parent + resolver = _load_resolver_with_dependencies( + file_path, + self._resolver_name, + dependent, + root, + ) if hasattr(resolver, "get_openapi_schema"): self._schemas.append(_model_to_dict(resolver.get_openapi_schema())) - except (ImportError, AttributeError, FileNotFoundError) as e: # pragma: no cover + except (ImportError, AttributeError, FileNotFoundError) as e: logger.warning(f"Failed to load resolver from {file_path}: {e}") self._cached_schema = self._merge_schemas() return self._cached_schema def get_openapi_json_schema(self) -> str: - """ - Generate the merged OpenAPI schema as a JSON string. - - This is the recommended method for CI/CD pipelines and build-time - schema generation, as the output can be directly written to a file - or used for API Gateway imports. - - Returns - ------- - str - The merged OpenAPI schema as formatted JSON. - - Example - ------- - >>> merge = OpenAPIMerge(title="API", version="1.0.0") - >>> merge.discover(path="./functions", pattern="**/handler.py") - >>> json_schema = merge.get_openapi_json_schema() - >>> with open("openapi.json", "w") as f: - ... f.write(json_schema) - """ + """Generate the merged OpenAPI schema as JSON string.""" from aws_lambda_powertools.event_handler.openapi.compat import model_json from aws_lambda_powertools.event_handler.openapi.models import OpenAPI schema = self.get_openapi_schema() return model_json(OpenAPI(**schema), by_alias=True, exclude_none=True, indent=2) - @property - def discovered_files(self) -> list[Path]: - """Get the list of discovered resolver files.""" - return self._discovered_files.copy() - def _merge_schemas(self) -> dict[str, Any]: """Merge all schemas into a single OpenAPI schema.""" cfg = self._config - # Build base schema merged: dict[str, Any] = { "openapi": cfg.openapi_version, "info": {"title": cfg.title, "version": cfg.version}, "servers": [_model_to_dict(s) for s in cfg.servers] if cfg.servers else [{"url": "/"}], } - # Add optional info fields self._add_optional_info_fields(merged, cfg) - # Merge paths and components merged_paths: dict[str, Any] = {} merged_components: dict[str, dict[str, Any]] = {} @@ -457,7 +434,6 @@ def _merge_schemas(self) -> dict[str, Any]: self._merge_paths(schema.get("paths", {}), merged_paths) self._merge_components(schema.get("components", {}), merged_components) - # Add security schemes from config if cfg.security_schemes: merged_components.setdefault("securitySchemes", {}).update(cfg.security_schemes) @@ -466,7 +442,6 @@ def _merge_schemas(self) -> dict[str, Any]: if merged_components: merged["components"] = merged_components - # Merge tags if merged_tags := self._merge_tags(): merged["tags"] = merged_tags @@ -514,12 +489,7 @@ def _handle_conflict(self, method: str, path: str, target: dict, operation: Any) target[path][method] = operation def _merge_components(self, source: dict[str, Any], target: dict[str, dict[str, Any]]) -> None: - """Merge components from source into target. - - Note: Components with the same name are silently overwritten (last wins). - This is intentional as component conflicts are typically user errors - (e.g., two handlers defining different 'User' schemas). - """ + """Merge components from source into target.""" for component_type, components in source.items(): target.setdefault(component_type, {}).update(components) @@ -527,7 +497,6 @@ def _merge_tags(self) -> list[dict[str, Any]]: """Merge tags from config and schemas.""" tags_map: dict[str, dict[str, Any]] = {} - # Config tags first for tag in self._config.tags or []: if isinstance(tag, str): tags_map[tag] = {"name": tag} @@ -535,11 +504,10 @@ def _merge_tags(self) -> list[dict[str, Any]]: tag_dict = _model_to_dict(tag) tags_map[tag_dict["name"]] = tag_dict - # Schema tags (don't override config) for schema in self._schemas: for tag in schema.get("tags", []): name = tag["name"] if isinstance(tag, dict) else tag if name not in tags_map: - tags_map[name] = tag if isinstance(tag, dict) else {"name": tag} # pragma: no cover + tags_map[name] = tag if isinstance(tag, dict) else {"name": tag} return list(tags_map.values()) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index e262613046c..966426f2dbb 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -485,7 +485,7 @@ This value will override the value of the failed response validation http code s We use the `Annotated` type to tell the 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: +In the following example, we use a new `Query` OpenAPI type to add [one out of many possible constraints](openapi.md#customizing-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 @@ -539,7 +539,7 @@ In the following example, we use a new `Query` OpenAPI type to add [one out of m #### 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). +Just like we learned in [query string validation](#validating-query-strings), we can use a new `Path` OpenAPI type to [add constraints](openapi.md#customizing-parameters). For example, we could validate that `` dynamic path should be no greater than three digits. @@ -555,7 +555,7 @@ For example, we could validate that `` dynamic path should be no greate We use the `Annotated` type to tell the Event Handler that a particular parameter is a header that needs to be validated. Also, we adhere to [HTTP RFC standards](https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2){target="_blank" rel="nofollow"}, which means we treat HTTP headers as case-insensitive. -In the following example, we use a new `Header` OpenAPI type to add [one out of many possible constraints](#customizing-openapi-parameters), which should read as: +In the following example, we use a new `Header` OpenAPI type to add [one out of many possible constraints](openapi.md#customizing-parameters), which should read as: * `correlation_id` is a header that must be present in the request * `correlation_id` should have 16 characters @@ -716,28 +716,15 @@ We provide pre-defined errors for the most popular ones based on [AWS Lambda API ### Enabling SwaggerUI -!!! note "This feature requires [data validation](#data-validation) feature to be enabled." +???+ tip "OpenAPI documentation has moved" + For complete OpenAPI documentation including Swagger UI customization, security schemes, and OpenAPI Merge for micro-functions, see the dedicated [OpenAPI documentation](openapi.md). -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 a **new route** | You'll need to expose the following path to Lambda: `/swagger`; ignore if you're routing this path already. | -| JS and CSS files are **embedded within Swagger HTML** | If you are not using an external CDN to serve Swagger UI assets, we embed JS and CSS directly into the HTML. To enhance performance, please consider enabling the `compress` option to minimize the size of HTTP requests. | -| Authorization data is **lost** on browser close/refresh | Use `enable_swagger(persist_authorization=True)` to persist authorization data, like OAuath 2.0 access tokens. | +Use `enable_swagger()` to serve interactive API documentation: ```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 @@ -1179,128 +1166,8 @@ This will enable full tracebacks errors in the response, print request and respo ### OpenAPI -When you enable [Data Validation](#data-validation), we use a combination of Pydantic Models and [OpenAPI](https://www.openapis.org/){target="_blank" rel="nofollow"} type annotations to add constraints to your API's parameters. - -???+ warning "OpenAPI schema version depends on the installed version of Pydantic" - Pydantic v1 generates [valid OpenAPI 3.0.3 schemas](https://docs.pydantic.dev/1.10/usage/schema/){target="_blank" rel="nofollow"}, and Pydantic v2 generates [valid OpenAPI 3.1.0 schemas](https://docs.pydantic.dev/latest/why/#json-schema){target="_blank" rel="nofollow"}. - -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 - ---8<-- "docs/core/event_handler/_openapi_customization_parameters.md" - -#### Customizing API operations - ---8<-- "docs/core/event_handler/_openapi_customization_operations.md" - -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 OpenAPI metadata - ---8<-- "docs/core/event_handler/_openapi_customization_metadata.md" - -Include extra parameters when exporting your OpenAPI specification to apply these customizations: - -=== "customizing_api_metadata.py" - - ```python hl_lines="8-16" - --8<-- "examples/event_handler_rest/src/customizing_api_metadata.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" - ``` - -#### Security schemes - -???-info "Does Powertools implement any of the security schemes?" - No. Powertools adds support for generating OpenAPI documentation with [security schemes](https://swagger.io/docs/specification/authentication/), but it doesn't implement any of the security schemes itself, so you must implement the security mechanisms separately. - -Security schemes are declared at the top-level first. You can reference them globally or on a per path _(operation)_ level. **However**, if you reference security schemes that are not defined at the top-level it will lead to a `SchemaValidationError` _(invalid OpenAPI spec)_. - -=== "Global OpenAPI security schemes" - - ```python title="security_schemes_global.py" hl_lines="17-27" - --8<-- "examples/event_handler_rest/src/security_schemes_global.py" - ``` - - 1. Using the oauth security scheme defined earlier, scoped to the "admin" role. - -=== "Per Operation security" - - ```python title="security_schemes_per_operation.py" hl_lines="17-26 30" - --8<-- "examples/event_handler_rest/src/security_schemes_per_operation.py" - ``` - - 1. Using the oauth security scheme defined bellow, scoped to the "admin" role. - -=== "Global security schemes and optional security per route" - - ```python title="security_schemes_global_and_optional.py" hl_lines="17-26 35" - --8<-- "examples/event_handler_rest/src/security_schemes_global_and_optional.py" - ``` - - 1. To make security optional in a specific route, an empty security requirement ({}) can be included in the array. - -OpenAPI 3 lets you describe APIs protected using the following security schemes: - -| Security Scheme | Type | Description | -| --------------------------------------------------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [HTTP auth](https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml){target="_blank"} | `HTTPBase` | HTTP authentication schemes using the Authorization header (e.g: [Basic auth](https://swagger.io/docs/specification/authentication/basic-authentication/){target="_blank"}, [Bearer](https://swagger.io/docs/specification/authentication/bearer-authentication/){target="_blank"}) | -| [API keys](https://swagger.io/docs/specification/authentication/api-keys/){target="_blank"} (e.g: query strings, cookies) | `APIKey` | API keys in headers, query strings or [cookies](https://swagger.io/docs/specification/authentication/cookie-authentication/){target="_blank"}. | -| [OAuth 2](https://swagger.io/docs/specification/authentication/oauth2/){target="_blank"} | `OAuth2` | Authorization protocol that gives an API client limited access to user data on a web server. | -| [OpenID Connect Discovery](https://swagger.io/docs/specification/authentication/openid-connect-discovery/){target="_blank"} | `OpenIdConnect` | Identity layer built [on top of the OAuth 2.0 protocol](https://openid.net/developers/how-connect-works/){target="_blank"} and supported by some OAuth 2.0. | -| [Mutual TLS](https://swagger.io/specification/#security-scheme-object){target="_blank"}. | `MutualTLS` | Client/server certificate mutual authentication scheme. | - -???-note "Using OAuth2 with the Swagger UI?" - You can use the `OAuth2Config` option to configure a default OAuth2 app on the generated Swagger UI. - - ```python hl_lines="10 15-18 22" - --8<-- "examples/event_handler_rest/src/swagger_with_oauth2.py" - ``` - -#### OpenAPI extensions - -For a better experience when working with Lambda and Amazon API Gateway, customers can define extensions using the `openapi_extensions` parameter. We support defining OpenAPI extensions at the following levels of the OpenAPI JSON Schema: Root, Servers, Operation, and Security Schemes. - -???+ warning - We do not support the `x-amazon-apigateway-any-method` and `x-amazon-apigateway-integrations` extensions. - -```python hl_lines="9 15 25 28" title="Adding OpenAPI extensions" ---8<-- "examples/event_handler_rest/src/working_with_openapi_extensions.py" -``` - -1. Server level -2. Operation level -3. Security scheme level -4. Root level +???+ tip "OpenAPI documentation has moved" + For complete OpenAPI documentation including customization, security schemes, extensions, and OpenAPI Merge for micro-functions, see the dedicated [OpenAPI documentation](openapi.md). ### Custom serializer diff --git a/docs/core/event_handler/openapi.md b/docs/core/event_handler/openapi.md new file mode 100644 index 00000000000..a9b78482dc6 --- /dev/null +++ b/docs/core/event_handler/openapi.md @@ -0,0 +1,450 @@ +--- +title: OpenAPI +description: Core utility - OpenAPI documentation and schema generation +--- + + + +Powertools for AWS Lambda supports automatic OpenAPI schema generation from your route definitions and type annotations. This includes Swagger UI integration, schema customization, and OpenAPI Merge for micro-functions architectures. + +## Key features + +* **Automatic schema generation** from Pydantic models and type annotations +* **Swagger UI** for interactive API documentation +* **OpenAPI Merge** for generating unified schemas from multiple Lambda handlers +* **Security schemes** support (OAuth2, API Key, HTTP auth, etc.) +* **Customizable** metadata, operations, and parameters + +## Swagger UI + +Behind the scenes, the [data validation](api_gateway.md#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 API. + +!!! note "This feature requires [data validation](api_gateway.md#data-validation) to be enabled." + +???+ warning "Important caveats" + | Caveat | Description | + | ------ | ----------- | + | Swagger UI is **publicly accessible by default** | Implement a [custom middleware](#customizing-swagger-ui) for authorization | + | You need to expose a **new route** | Expose `/swagger` path to Lambda | + | JS and CSS files are **embedded within Swagger HTML** | Consider enabling `compress` option for better performance | + | Authorization data is **lost** on browser close/refresh | Use `enable_swagger(persist_authorization=True)` to persist | + +=== "enabling_swagger.py" + + ```python hl_lines="12-13" + --8<-- "examples/event_handler_rest/src/enabling_swagger.py" + ``` + + 1. `enable_swagger` creates a route to serve Swagger UI and allows quick customizations. + +Here's an example of what it looks like by default: + +![Swagger UI picture](../../media/swagger.png) + +### Customizing Swagger UI + +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. + +=== "customizing_swagger.py" + + ```python hl_lines="10" + --8<-- "examples/event_handler_rest/src/customizing_swagger.py" + ``` + +=== "customizing_swagger_middlewares.py" + + Use middleware for security headers, authentication, or other request processing. + + ```python hl_lines="7 13-18 21" + --8<-- "examples/event_handler_rest/src/customizing_swagger_middlewares.py" + ``` + +## Customization + +???+ warning "OpenAPI schema version depends on the installed version of Pydantic" + Pydantic v1 generates [valid OpenAPI 3.0.3 schemas](https://docs.pydantic.dev/1.10/usage/schema/){target="_blank" rel="nofollow"}, and Pydantic v2 generates [valid OpenAPI 3.1.0 schemas](https://docs.pydantic.dev/latest/why/#json-schema){target="_blank" rel="nofollow"}. + +### Customizing parameters + +--8<-- "docs/core/event_handler/_openapi_customization_parameters.md" + +### Customizing operations + +--8<-- "docs/core/event_handler/_openapi_customization_operations.md" + +To implement these customizations, include extra parameters when defining your routes: + +=== "customizing_api_operations.py" + + ```python hl_lines="11-20" + --8<-- "examples/event_handler_rest/src/customizing_api_operations.py" + ``` + +### Customizing metadata + +--8<-- "docs/core/event_handler/_openapi_customization_metadata.md" + +Include extra parameters when exporting your OpenAPI specification: + +=== "customizing_api_metadata.py" + + ```python hl_lines="8-16" + --8<-- "examples/event_handler_rest/src/customizing_api_metadata.py" + ``` + +### Security schemes + +???- info "Does Powertools implement any of the security schemes?" + No. Powertools adds support for generating OpenAPI documentation with [security schemes](https://swagger.io/docs/specification/authentication/), but you must implement the security mechanisms separately. + +Security schemes are declared at the top-level first, then referenced globally or per operation. + +=== "Global security schemes" + + ```python hl_lines="17-27" + --8<-- "examples/event_handler_rest/src/security_schemes_global.py" + ``` + + 1. Using the oauth security scheme defined earlier, scoped to the "admin" role. + +=== "Per operation security" + + ```python hl_lines="17-26 30" + --8<-- "examples/event_handler_rest/src/security_schemes_per_operation.py" + ``` + + 1. Using the oauth security scheme scoped to the "admin" role. + +=== "Optional security per route" + + ```python hl_lines="17-26 35" + --8<-- "examples/event_handler_rest/src/security_schemes_global_and_optional.py" + ``` + + 1. An empty security requirement ({}) makes security optional for this route. + +OpenAPI 3 supports these security schemes: + +| Security Scheme | Type | Description | +| --------------- | ---- | ----------- | +| [HTTP auth](https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml){target="_blank"} | `HTTPBase` | HTTP authentication (Basic, Bearer) | +| [API keys](https://swagger.io/docs/specification/authentication/api-keys/){target="_blank"} | `APIKey` | API keys in headers, query strings or cookies | +| [OAuth 2](https://swagger.io/docs/specification/authentication/oauth2/){target="_blank"} | `OAuth2` | OAuth 2.0 authorization | +| [OpenID Connect](https://swagger.io/docs/specification/authentication/openid-connect-discovery/){target="_blank"} | `OpenIdConnect` | OpenID Connect Discovery | +| [Mutual TLS](https://swagger.io/specification/#security-scheme-object){target="_blank"} | `MutualTLS` | Client/server certificate authentication | + +???- note "Using OAuth2 with Swagger UI?" + Use `OAuth2Config` to configure a default OAuth2 app: + + ```python hl_lines="10 15-18 22" + --8<-- "examples/event_handler_rest/src/swagger_with_oauth2.py" + ``` + +### OpenAPI extensions + +Define extensions using `openapi_extensions` parameter at Root, Servers, Operation, and Security Schemes levels. + +???+ warning + We do not support `x-amazon-apigateway-any-method` and `x-amazon-apigateway-integrations` extensions. + +=== "working_with_openapi_extensions.py" + + ```python hl_lines="9 15 25 28" + --8<-- "examples/event_handler_rest/src/working_with_openapi_extensions.py" + ``` + + 1. Server level + 2. Operation level + 3. Security scheme level + 4. Root level + +## OpenAPI Merge + +OpenAPI Merge generates a unified OpenAPI schema from multiple Lambda handlers. This is designed for micro-functions architectures where each Lambda has its own resolver. + +### Why OpenAPI Merge? + +In a micro-functions architecture, each Lambda function handles a specific domain (users, orders, payments). Each has its own resolver with routes, but you need a single OpenAPI specification for documentation and API Gateway imports. + +```mermaid +graph LR + A[Users Lambda] --> D[OpenAPI Merge] + B[Orders Lambda] --> D + C[Payments Lambda] --> D + D --> E[Unified OpenAPI Schema] + E --> F[Swagger UI] + E --> G[API Gateway Import] +``` + +### How it works + +OpenAPI Merge uses AST (Abstract Syntax Tree) analysis to detect resolver instances in your handler files. **No code is executed during discovery** - it's pure static analysis. This means: + +* No side effects from importing handler code +* No Lambda cold starts +* No security concerns from arbitrary code execution +* Fast discovery across many files + +### Discovery parameters + +The `discover()` method accepts the following parameters: + +| Parameter | Type | Default | Description | +| --------- | ---- | ------- | ----------- | +| `path` | `str` or `Path` | required | Root directory to search for handler files | +| `pattern` | `str` or `list[str]` | `"handler.py"` | Glob pattern(s) to match handler files | +| `exclude` | `list[str]` | `["**/tests/**", "**/__pycache__/**", "**/.venv/**"]` | Patterns to exclude from discovery | +| `resolver_name` | `str` | `"app"` | Variable name of the resolver instance in handler files | +| `recursive` | `bool` | `False` | Whether to search recursively in subdirectories | +| `project_root` | `str` or `Path` | Same as `path` | Root directory for resolving Python imports | + +#### Pattern examples + +Patterns use glob syntax: + +| Pattern | Matches | +| ------- | ------- | +| `handler.py` | Files named exactly `handler.py` in the root directory | +| `*_handler.py` | Files ending with `_handler.py` (e.g., `users_handler.py`) | +| `**/*.py` | All Python files recursively (requires `recursive=True`) | +| `["handler.py", "api.py"]` | Multiple patterns | + +#### Recursive search + +By default, `recursive=False` searches only in the specified `path` directory. Set `recursive=True` to search subdirectories: + +```python +# Only searches in ./src (not subdirectories) +merge.discover(path="./src", pattern="handler.py") + +# Searches ./src and all subdirectories +merge.discover(path="./src", pattern="handler.py", recursive=True) + +# Pattern with **/ also searches recursively +merge.discover(path="./src", pattern="**/handler.py") +``` + +#### Project root for imports + +When handler files use absolute imports (e.g., `from myapp.utils.resolver import app`), set `project_root` to the directory that serves as the Python package root: + +```python +merge.discover( + path="./src/myapp/handlers", + pattern="*.py", + project_root="./src", # Allows resolving "from myapp.x import y" +) +``` + +### Getting started example + +Here's a typical micro-functions project structure and how to configure OpenAPI Merge: + +```text +my-api/ +├── functions/ +│ ├── users/ +│ │ └── handler.py # app = APIGatewayRestResolver() with /users routes +│ ├── orders/ +│ │ └── handler.py # app = APIGatewayRestResolver() with /orders routes +│ ├── payments/ +│ │ └── handler.py # app = APIGatewayRestResolver() with /payments routes +│ └── docs/ +│ └── handler.py # Dedicated Lambda to serve unified OpenAPI docs +├── scripts/ +│ └── generate_openapi.py # CI/CD script to generate openapi.json +└── template.yaml # SAM/CloudFormation template +``` + +Each handler file defines its own resolver with domain-specific routes: + +=== "functions/users/handler.py" + + ```python + from aws_lambda_powertools.event_handler import APIGatewayRestResolver + + app = APIGatewayRestResolver(enable_validation=True) + + @app.get("/users") + def list_users(): + return {"users": []} + + @app.get("/users/") + def get_user(user_id: str): + return {"id": user_id, "name": "John"} + + def handler(event, context): + return app.resolve(event, context) + ``` + +=== "functions/orders/handler.py" + + ```python + from aws_lambda_powertools.event_handler import APIGatewayRestResolver + + app = APIGatewayRestResolver(enable_validation=True) + + @app.get("/orders") + def list_orders(): + return {"orders": []} + + @app.post("/orders") + def create_order(): + return {"id": "order-123"} + + def handler(event, context): + return app.resolve(event, context) + ``` + +To generate a unified OpenAPI schema, you have two options: + +=== "Option 1: CI/CD script" + + Generate `openapi.json` at build time: + + ```python + # scripts/generate_openapi.py + from pathlib import Path + from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge + + merge = OpenAPIMerge( + title="My API", + version="1.0.0", + description="Unified API documentation", + ) + + merge.discover( + path="./functions", + pattern="handler.py", + exclude=["**/docs/**"], # Exclude the docs Lambda + recursive=True, + ) + + output = Path("openapi.json") + output.write_text(merge.get_openapi_json_schema()) + print(f"Generated {output}") + ``` + +=== "Option 2: Dedicated docs Lambda" + + Serve Swagger UI from a dedicated Lambda: + + ```python + # functions/docs/handler.py + from aws_lambda_powertools.event_handler import APIGatewayRestResolver + + app = APIGatewayRestResolver() + + app.configure_openapi_merge( + path="../", # Parent directory containing other handlers + pattern="handler.py", + exclude=["**/docs/**"], + recursive=True, + title="My API", + version="1.0.0", + ) + + app.enable_swagger(path="/") + + def handler(event, context): + return app.resolve(event, context) + ``` + +### Standalone class + +Use `OpenAPIMerge` class to generate schemas. This is pure Python code where you control the paths and output. + +=== "openapi_merge_standalone.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_standalone.py" + ``` + +=== "openapi_merge_with_exclusions.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_with_exclusions.py" + ``` + +=== "openapi_merge_multiple_patterns.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_multiple_patterns.py" + ``` + +### Resolver integration + +Use `configure_openapi_merge()` on any resolver to serve merged schemas via Swagger UI. This is useful when you want a dedicated Lambda to serve the unified documentation. + +=== "openapi_merge_resolver.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_resolver.py" + ``` + +???+ warning "Routes from other Lambdas are documentation only" + The merged schema includes routes from all discovered handlers for documentation purposes. However, only routes defined in the current Lambda are actually executable. Other routes exist only in the OpenAPI spec - unless you configure API Gateway to route them to their respective Lambdas. + +### Shared resolver pattern + +In some architectures, instead of each handler file defining its own resolver, you have a central resolver file that is imported by multiple route files. Each route file registers its routes on the shared resolver instance. + +```text +src/ +├── myapp/ +│ ├── resolver.py # Defines: app = APIGatewayRestResolver() +│ ├── users_routes.py # Imports app, registers /users routes +│ ├── orders_routes.py # Imports app, registers /orders routes +│ └── payments_routes.py # Imports app, registers /payments routes +``` + +OpenAPI Merge automatically detects this pattern. When you point `discover()` to the resolver file, it finds all files that import from it and loads them to ensure all routes are registered before extracting the schema. + +=== "shared_resolver.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_shared_resolver.py" + ``` + +=== "shared_users_routes.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_shared_users_routes.py" + ``` + +=== "shared_orders_routes.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_shared_orders_routes.py" + ``` + +=== "Discovery" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_shared_discovery.py" + ``` + +### Conflict handling + +When the same path+method is defined in multiple handlers, use `on_conflict` to control behavior: + +| Strategy | Behavior | +| -------- | -------- | +| `warn` (default) | Log warning, keep first definition | +| `error` | Raise `OpenAPIMergeError` | +| `first` | Silently keep first definition | +| `last` | Use last definition (override) | + +=== "openapi_merge_conflict.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_conflict.py" + ``` + +### Full configuration + +=== "openapi_merge_full_config.py" + + ```python + --8<-- "examples/event_handler_rest/src/openapi_merge_full_config.py" + ``` diff --git a/examples/event_handler_rest/src/openapi_merge_conflict.py b/examples/event_handler_rest/src/openapi_merge_conflict.py new file mode 100644 index 00000000000..f7b90c7a946 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_conflict.py @@ -0,0 +1,9 @@ +from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge + +merge = OpenAPIMerge( + title="API", + version="1.0.0", + on_conflict="error", # Raise OpenAPIMergeError on conflicts +) + +merge.discover(path="./src", pattern="**/handler.py") diff --git a/examples/event_handler_rest/src/openapi_merge_full_config.py b/examples/event_handler_rest/src/openapi_merge_full_config.py new file mode 100644 index 00000000000..a52549c6aab --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_full_config.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge +from aws_lambda_powertools.event_handler.openapi.models import Contact, License, Server, Tag + +merge = OpenAPIMerge( + title="My API", + version="1.0.0", + summary="API summary", + description="Full API description", + terms_of_service="https://example.com/tos", + contact=Contact(name="Support", email="support@example.com"), + license_info=License(name="MIT"), + servers=[Server(url="https://api.example.com")], + tags=[Tag(name="users", description="User operations")], + on_conflict="warn", +) + +merge.discover(path="./src", pattern="**/handler.py", recursive=True) +schema = merge.get_openapi_json_schema() diff --git a/examples/event_handler_rest/src/openapi_merge_multiple_patterns.py b/examples/event_handler_rest/src/openapi_merge_multiple_patterns.py new file mode 100644 index 00000000000..9cf2a46fc8e --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_multiple_patterns.py @@ -0,0 +1,9 @@ +from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge + +merge = OpenAPIMerge(title="API", version="1.0.0") + +merge.discover( + path="./src", + pattern=["handler.py", "api.py", "*_routes.py"], + recursive=True, +) diff --git a/examples/event_handler_rest/src/openapi_merge_resolver.py b/examples/event_handler_rest/src/openapi_merge_resolver.py new file mode 100644 index 00000000000..cc8cccbf2a5 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_resolver.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools.event_handler import APIGatewayRestResolver + +app = APIGatewayRestResolver() + +# Configure merge - discovers handlers but doesn't execute them +app.configure_openapi_merge( + path="./functions", + pattern="**/handler.py", + title="My API", + version="1.0.0", +) + +# Swagger UI will show the merged schema +app.enable_swagger(path="/docs") + + +def handler(event, context): + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/openapi_merge_shared_discovery.py b/examples/event_handler_rest/src/openapi_merge_shared_discovery.py new file mode 100644 index 00000000000..13cdfc3cef8 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_shared_discovery.py @@ -0,0 +1,13 @@ +from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge + +merge = OpenAPIMerge(title="API", version="1.0.0") + +# Use project_root to resolve absolute imports like "from myapp.shared_resolver import app" +merge.discover( + path="./src/myapp", + pattern="shared_resolver.py", + project_root="./src", # Root for import resolution +) + +# Automatically finds users_routes.py and orders_routes.py +# that import from shared_resolver.py diff --git a/examples/event_handler_rest/src/openapi_merge_shared_orders_routes.py b/examples/event_handler_rest/src/openapi_merge_shared_orders_routes.py new file mode 100644 index 00000000000..2682a9c6ab1 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_shared_orders_routes.py @@ -0,0 +1,7 @@ +# Imports and registers routes on shared resolver - orders_routes.py +from myapp.shared_resolver import app # type: ignore[import-not-found] + + +@app.get("/orders") +def get_orders(): + return [] diff --git a/examples/event_handler_rest/src/openapi_merge_shared_resolver.py b/examples/event_handler_rest/src/openapi_merge_shared_resolver.py new file mode 100644 index 00000000000..decaaa3c829 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_shared_resolver.py @@ -0,0 +1,4 @@ +# Central resolver definition - shared_resolver.py +from aws_lambda_powertools.event_handler import APIGatewayRestResolver + +app = APIGatewayRestResolver() diff --git a/examples/event_handler_rest/src/openapi_merge_shared_users_routes.py b/examples/event_handler_rest/src/openapi_merge_shared_users_routes.py new file mode 100644 index 00000000000..de4c87069de --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_shared_users_routes.py @@ -0,0 +1,12 @@ +# Imports and registers routes on shared resolver - users_routes.py +from myapp.shared_resolver import app # type: ignore[import-not-found] + + +@app.get("/users") +def get_users(): + return [] + + +@app.get("/users/") +def get_user(user_id: str): + return {"id": user_id} diff --git a/examples/event_handler_rest/src/openapi_merge_standalone.py b/examples/event_handler_rest/src/openapi_merge_standalone.py new file mode 100644 index 00000000000..ef056974901 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_standalone.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge + +merge = OpenAPIMerge( + title="My Unified API", + version="1.0.0", + description="Consolidated API from multiple Lambda functions", +) + +# Discover handlers +merge.discover( + path="./src/functions", + pattern="*_handler.py", + recursive=True, +) + +# Generate schema +schema_json = merge.get_openapi_json_schema() + +# Write to file +output = Path("openapi.json") +output.write_text(schema_json) diff --git a/examples/event_handler_rest/src/openapi_merge_with_exclusions.py b/examples/event_handler_rest/src/openapi_merge_with_exclusions.py new file mode 100644 index 00000000000..b781857ddc5 --- /dev/null +++ b/examples/event_handler_rest/src/openapi_merge_with_exclusions.py @@ -0,0 +1,10 @@ +from aws_lambda_powertools.event_handler.openapi import OpenAPIMerge + +merge = OpenAPIMerge(title="API", version="1.0.0") + +merge.discover( + path="./src", + pattern="**/*_handler.py", + exclude=["**/tests/**", "**/legacy/**"], + recursive=True, +) diff --git a/mkdocs.yml b/mkdocs.yml index db49e9e45f7..3f9c26a908c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ nav: - Datadog: core/metrics/datadog.md - Event Handler: - core/event_handler/api_gateway.md + - core/event_handler/openapi.md - core/event_handler/appsync.md - core/event_handler/appsync_events.md - core/event_handler/bedrock_agents.md diff --git a/tests/functional/event_handler/_pydantic/merge_handlers/shared/__init__.py b/tests/functional/event_handler/_pydantic/merge_handlers/shared/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/event_handler/_pydantic/merge_handlers/shared/categories_routes.py b/tests/functional/event_handler/_pydantic/merge_handlers/shared/categories_routes.py new file mode 100644 index 00000000000..7029ad332c7 --- /dev/null +++ b/tests/functional/event_handler/_pydantic/merge_handlers/shared/categories_routes.py @@ -0,0 +1,11 @@ +"""Categories routes - imports shared resolver and registers routes.""" + +from __future__ import annotations + +from tests.functional.event_handler._pydantic.merge_handlers.shared.resolver import app + + +@app.get("/categories") +def get_categories() -> list[dict]: + """Get all categories.""" + return [] diff --git a/tests/functional/event_handler/_pydantic/merge_handlers/shared/products_routes.py b/tests/functional/event_handler/_pydantic/merge_handlers/shared/products_routes.py new file mode 100644 index 00000000000..84d9d4bbde5 --- /dev/null +++ b/tests/functional/event_handler/_pydantic/merge_handlers/shared/products_routes.py @@ -0,0 +1,17 @@ +"""Products routes - imports shared resolver and registers routes.""" + +from __future__ import annotations + +from tests.functional.event_handler._pydantic.merge_handlers.shared.resolver import app + + +@app.get("/products") +def get_products() -> list[dict]: + """Get all products.""" + return [] + + +@app.get("/products/") +def get_product(product_id: str) -> dict: + """Get a product by ID.""" + return {"id": product_id} diff --git a/tests/functional/event_handler/_pydantic/merge_handlers/shared/resolver.py b/tests/functional/event_handler/_pydantic/merge_handlers/shared/resolver.py new file mode 100644 index 00000000000..8a7a472b216 --- /dev/null +++ b/tests/functional/event_handler/_pydantic/merge_handlers/shared/resolver.py @@ -0,0 +1,5 @@ +"""Shared resolver - routes are registered by other files that import this.""" + +from aws_lambda_powertools.event_handler import APIGatewayRestResolver + +app = APIGatewayRestResolver() diff --git a/tests/functional/event_handler/_pydantic/test_openapi_merge.py b/tests/functional/event_handler/_pydantic/test_openapi_merge.py index b4dc1d70232..6b138b57cea 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_merge.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_merge.py @@ -367,3 +367,36 @@ def test_openapi_merge_schema_is_cached(): # AND paths should not be duplicated assert len([p for p in schema1["paths"] if p == "/users"]) == 1 + + +def test_openapi_merge_shared_resolver_pattern(): + # GIVEN a shared resolver pattern where: + # - resolver.py defines the resolver + # - products_routes.py and categories_routes.py import it and register routes + merge = OpenAPIMerge(title="Shared Resolver API", version="1.0.0") + + # WHEN discovering with project_root set to allow absolute imports + shared_path = MERGE_HANDLERS_PATH / "shared" + project_root = Path(__file__).parent.parent.parent.parent.parent # repo root + + files = merge.discover( + path=shared_path, + pattern="resolver.py", + project_root=project_root, + ) + + # THEN it should find the resolver file + assert len(files) == 1 + assert files[0].name == "resolver.py" + + # AND it should find dependent files that import the resolver + dependent = merge.dependent_files.get(files[0], []) + dependent_names = [f.name for f in dependent] + assert "products_routes.py" in dependent_names + assert "categories_routes.py" in dependent_names + + # AND the merged schema should include routes from all dependent files + schema = merge.get_openapi_schema() + assert "/products" in schema["paths"] + assert "/products/{product_id}" in schema["paths"] + assert "/categories" in schema["paths"] diff --git a/tests/unit/event_handler/openapi/test_openapi_merge.py b/tests/unit/event_handler/openapi/test_openapi_merge.py index 21500145b35..bce18b62dea 100644 --- a/tests/unit/event_handler/openapi/test_openapi_merge.py +++ b/tests/unit/event_handler/openapi/test_openapi_merge.py @@ -10,7 +10,7 @@ _discover_resolver_files, _file_has_resolver, _is_excluded, - _load_resolver, + _load_resolver_with_dependencies, ) MERGE_HANDLERS_PATH = Path(__file__).parents[3] / "functional/event_handler/_pydantic/merge_handlers" @@ -71,7 +71,7 @@ def test_is_excluded_with_file_pattern(): def test_load_resolver_file_not_found(): with pytest.raises(FileNotFoundError): - _load_resolver(Path("/non/existent/file.py"), "app") + _load_resolver_with_dependencies(Path("/non/existent/file.py"), "app", [], Path("/")) def test_load_resolver_not_found_in_module(tmp_path: Path): @@ -79,7 +79,7 @@ def test_load_resolver_not_found_in_module(tmp_path: Path): handler_file.write_text("x = 1") with pytest.raises(AttributeError, match="Resolver 'app' not found"): - _load_resolver(handler_file, "app") + _load_resolver_with_dependencies(handler_file, "app", [], tmp_path) def test_load_resolver_success(tmp_path: Path): @@ -93,6 +93,6 @@ def test_endpoint(): return {"test": True} """) - resolver = _load_resolver(handler_file, "app") + resolver = _load_resolver_with_dependencies(handler_file, "app", [], tmp_path) assert resolver is not None assert hasattr(resolver, "get_openapi_schema")