Skip to content

Commit 3093afe

Browse files
committed
refactor: reduce cognitive complexity in OpenAPI schema generation
- Refactored get_openapi_schema method to reduce cognitive complexity from 27 to 15 - Split into 7 helper methods for better maintainability - Enhanced error handling and code organization - Refactored find_missing_component_references to reduce complexity from 23 to 15 - Split into 5 helper methods with proper separation of concerns - Fixed null pointer bug in _get_existing_schemas function - Reorganized examples directory structure - Moved upload file examples to examples/event_handler/ - Updated documentation and imports accordingly - All OpenAPI tests passing (220/220)
1 parent fa14b41 commit 3093afe

File tree

5 files changed

+231
-99
lines changed

5 files changed

+231
-99
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 123 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,72 +1774,114 @@ def get_openapi_schema(
17741774
OpenAPI: pydantic model
17751775
The OpenAPI schema as a pydantic model.
17761776
"""
1777+
# Resolve configuration with fallbacks to openapi_config
1778+
config = self._resolve_openapi_config(
1779+
title=title,
1780+
version=version,
1781+
openapi_version=openapi_version,
1782+
summary=summary,
1783+
description=description,
1784+
tags=tags,
1785+
servers=servers,
1786+
terms_of_service=terms_of_service,
1787+
contact=contact,
1788+
license_info=license_info,
1789+
security_schemes=security_schemes,
1790+
security=security,
1791+
external_documentation=external_documentation,
1792+
openapi_extensions=openapi_extensions,
1793+
)
17771794

1778-
# DEPRECATION: Will be removed in v4.0.0. Use configure_api() instead.
1779-
# Maintained for backwards compatibility.
1780-
# See: https://github.com/aws-powertools/powertools-lambda-python/issues/6122
1781-
if title == DEFAULT_OPENAPI_TITLE and self.openapi_config.title:
1782-
title = self.openapi_config.title
1783-
1784-
if version == DEFAULT_API_VERSION and self.openapi_config.version:
1785-
version = self.openapi_config.version
1786-
1787-
if openapi_version == DEFAULT_OPENAPI_VERSION and self.openapi_config.openapi_version:
1788-
openapi_version = self.openapi_config.openapi_version
1789-
1790-
summary = summary or self.openapi_config.summary
1791-
description = description or self.openapi_config.description
1792-
tags = tags or self.openapi_config.tags
1793-
servers = servers or self.openapi_config.servers
1794-
terms_of_service = terms_of_service or self.openapi_config.terms_of_service
1795-
contact = contact or self.openapi_config.contact
1796-
license_info = license_info or self.openapi_config.license_info
1797-
security_schemes = security_schemes or self.openapi_config.security_schemes
1798-
security = security or self.openapi_config.security
1799-
external_documentation = external_documentation or self.openapi_config.external_documentation
1800-
openapi_extensions = openapi_extensions or self.openapi_config.openapi_extensions
1795+
# Build base OpenAPI structure
1796+
output = self._build_base_openapi_structure(config)
18011797

1802-
from pydantic.json_schema import GenerateJsonSchema
1798+
# Process routes and build paths/components
1799+
paths, definitions = self._process_routes_for_openapi(config["security_schemes"])
18031800

1804-
from aws_lambda_powertools.event_handler.openapi.compat import (
1805-
get_compat_model_name_map,
1806-
get_definitions,
1807-
)
1808-
from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Tag
1809-
from aws_lambda_powertools.event_handler.openapi.types import (
1810-
COMPONENT_REF_TEMPLATE,
1811-
)
1801+
# Build final components and paths
1802+
components = self._build_openapi_components(definitions, config["security_schemes"])
1803+
output.update(self._finalize_openapi_output(components, config["tags"], paths, config["external_documentation"]))
18121804

1813-
openapi_version = self._determine_openapi_version(openapi_version)
1805+
# Apply schema fixes and return result
1806+
return self._apply_schema_fixes(output)
1807+
1808+
def _resolve_openapi_config(self, **kwargs) -> dict[str, Any]:
1809+
"""Resolve OpenAPI configuration with fallbacks to openapi_config."""
1810+
# DEPRECATION: Will be removed in v4.0.0. Use configure_api() instead.
1811+
# Maintained for backwards compatibility.
1812+
# See: https://github.com/aws-powertools/powertools-lambda-python/issues/6122
1813+
resolved = {}
1814+
1815+
# Handle title with fallback
1816+
resolved["title"] = kwargs["title"]
1817+
if kwargs["title"] == DEFAULT_OPENAPI_TITLE and self.openapi_config.title:
1818+
resolved["title"] = self.openapi_config.title
1819+
1820+
# Handle version with fallback
1821+
resolved["version"] = kwargs["version"]
1822+
if kwargs["version"] == DEFAULT_API_VERSION and self.openapi_config.version:
1823+
resolved["version"] = self.openapi_config.version
1824+
1825+
# Handle openapi_version with fallback
1826+
resolved["openapi_version"] = kwargs["openapi_version"]
1827+
if kwargs["openapi_version"] == DEFAULT_OPENAPI_VERSION and self.openapi_config.openapi_version:
1828+
resolved["openapi_version"] = self.openapi_config.openapi_version
1829+
1830+
# Resolve other fields with fallbacks
1831+
resolved.update({
1832+
"summary": kwargs["summary"] or self.openapi_config.summary,
1833+
"description": kwargs["description"] or self.openapi_config.description,
1834+
"tags": kwargs["tags"] or self.openapi_config.tags,
1835+
"servers": kwargs["servers"] or self.openapi_config.servers,
1836+
"terms_of_service": kwargs["terms_of_service"] or self.openapi_config.terms_of_service,
1837+
"contact": kwargs["contact"] or self.openapi_config.contact,
1838+
"license_info": kwargs["license_info"] or self.openapi_config.license_info,
1839+
"security_schemes": kwargs["security_schemes"] or self.openapi_config.security_schemes,
1840+
"security": kwargs["security"] or self.openapi_config.security,
1841+
"external_documentation": kwargs["external_documentation"] or self.openapi_config.external_documentation,
1842+
"openapi_extensions": kwargs["openapi_extensions"] or self.openapi_config.openapi_extensions,
1843+
})
1844+
1845+
return resolved
1846+
1847+
def _build_base_openapi_structure(self, config: dict[str, Any]) -> dict[str, Any]:
1848+
"""Build the base OpenAPI structure with info, servers, and security."""
1849+
openapi_version = self._determine_openapi_version(config["openapi_version"])
18141850

18151851
# Start with the bare minimum required for a valid OpenAPI schema
1816-
info: dict[str, Any] = {"title": title, "version": version}
1852+
info: dict[str, Any] = {"title": config["title"], "version": config["version"]}
18171853

18181854
optional_fields = {
1819-
"summary": summary,
1820-
"description": description,
1821-
"termsOfService": terms_of_service,
1822-
"contact": contact,
1823-
"license": license_info,
1855+
"summary": config["summary"],
1856+
"description": config["description"],
1857+
"termsOfService": config["terms_of_service"],
1858+
"contact": config["contact"],
1859+
"license": config["license_info"],
18241860
}
18251861

18261862
info.update({field: value for field, value in optional_fields.items() if value})
18271863

1864+
openapi_extensions = config["openapi_extensions"]
18281865
if not isinstance(openapi_extensions, dict):
18291866
openapi_extensions = {}
18301867

1831-
output: dict[str, Any] = {
1868+
return {
18321869
"openapi": openapi_version,
18331870
"info": info,
1834-
"servers": self._get_openapi_servers(servers),
1835-
"security": self._get_openapi_security(security, security_schemes),
1871+
"servers": self._get_openapi_servers(config["servers"]),
1872+
"security": self._get_openapi_security(config["security"], config["security_schemes"]),
18361873
**openapi_extensions,
18371874
}
18381875

1839-
if external_documentation:
1840-
output["externalDocs"] = external_documentation
1876+
def _process_routes_for_openapi(self, security_schemes: dict[str, SecurityScheme] | None) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
1877+
"""Process all routes and build paths and definitions."""
1878+
from pydantic.json_schema import GenerateJsonSchema
1879+
from aws_lambda_powertools.event_handler.openapi.compat import (
1880+
get_compat_model_name_map,
1881+
get_definitions,
1882+
)
1883+
from aws_lambda_powertools.event_handler.openapi.types import COMPONENT_REF_TEMPLATE
18411884

1842-
components: dict[str, dict[str, Any]] = {}
18431885
paths: dict[str, dict[str, Any]] = {}
18441886
operation_ids: set[str] = set()
18451887

@@ -1857,15 +1899,8 @@ def get_openapi_schema(
18571899

18581900
# Add routes to the OpenAPI schema
18591901
for route in all_routes:
1860-
if route.security and not _validate_openapi_security_parameters(
1861-
security=route.security,
1862-
security_schemes=security_schemes,
1863-
):
1864-
raise SchemaValidationError(
1865-
"Security configuration was not found in security_schemas or security_schema was not defined. "
1866-
"See: https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/#security-schemes",
1867-
)
1868-
1902+
self._validate_route_security(route, security_schemes)
1903+
18691904
if not route.include_in_schema:
18701905
continue
18711906

@@ -1883,19 +1918,50 @@ def get_openapi_schema(
18831918
if path_definitions:
18841919
definitions.update(path_definitions)
18851920

1921+
return paths, definitions
1922+
1923+
def _validate_route_security(self, route, security_schemes: dict[str, SecurityScheme] | None) -> None:
1924+
"""Validate route security configuration."""
1925+
if route.security and not _validate_openapi_security_parameters(
1926+
security=route.security,
1927+
security_schemes=security_schemes,
1928+
):
1929+
raise SchemaValidationError(
1930+
"Security configuration was not found in security_schemas or security_schema was not defined. "
1931+
"See: https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/#security-schemes",
1932+
)
1933+
1934+
def _build_openapi_components(self, definitions: dict[str, dict[str, Any]], security_schemes: dict[str, SecurityScheme] | None) -> dict[str, dict[str, Any]]:
1935+
"""Build the components section of the OpenAPI schema."""
1936+
components: dict[str, dict[str, Any]] = {}
1937+
18861938
if definitions:
18871939
components["schemas"] = self._generate_schemas(definitions)
18881940
if security_schemes:
18891941
components["securitySchemes"] = security_schemes
1942+
1943+
return components
1944+
1945+
def _finalize_openapi_output(self, components: dict[str, dict[str, Any]], tags, paths: dict[str, dict[str, Any]], external_documentation) -> dict[str, Any]:
1946+
"""Finalize the OpenAPI output with components, tags, and paths."""
1947+
from aws_lambda_powertools.event_handler.openapi.models import PathItem, Tag
1948+
1949+
output = {}
1950+
18901951
if components:
18911952
output["components"] = components
18921953
if tags:
18931954
output["tags"] = [Tag(name=tag) if isinstance(tag, str) else tag for tag in tags]
1955+
if external_documentation:
1956+
output["externalDocs"] = external_documentation
18941957

18951958
output["paths"] = {k: PathItem(**v) for k, v in paths.items()}
1959+
1960+
return output
18961961

1897-
# Apply patches to fix any issues with the OpenAPI schema
1898-
# Import here to avoid circular imports
1962+
def _apply_schema_fixes(self, output: dict[str, Any]) -> OpenAPI:
1963+
"""Apply schema fixes and return the final OpenAPI model."""
1964+
from aws_lambda_powertools.event_handler.openapi.models import OpenAPI
18991965
from aws_lambda_powertools.event_handler.openapi.upload_file_fix import fix_upload_file_schema
19001966

19011967
# First create the OpenAPI model
@@ -1906,9 +1972,7 @@ def get_openapi_schema(
19061972
fixed_dict = fix_upload_file_schema(result_dict)
19071973

19081974
# Reconstruct the model with the fixed dict
1909-
result = OpenAPI(**fixed_dict)
1910-
1911-
return result
1975+
return OpenAPI(**fixed_dict)
19121976

19131977
@staticmethod
19141978
def _get_openapi_servers(servers: list[Server] | None) -> list[Server]:

aws_lambda_powertools/event_handler/openapi/upload_file_fix.py

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -62,43 +62,86 @@ def find_missing_component_references(schema_dict: dict[str, Any]) -> list[tuple
6262
A list of tuples containing (reference_name, path_url)
6363
"""
6464
paths = schema_dict.get("paths", {})
65+
existing_schemas = _get_existing_schemas(schema_dict)
6566
missing_components: list[tuple[str, str]] = []
6667

67-
# Find all referenced component names that don't exist in the schema
6868
for path_url, path_item in paths.items():
6969
if not isinstance(path_item, dict):
7070
continue
71+
_check_path_for_missing_components(path_item, path_url, existing_schemas, missing_components)
7172

72-
for _method, operation in path_item.items():
73-
if not isinstance(operation, dict):
74-
continue
73+
return missing_components
7574

76-
if "requestBody" not in operation or not operation["requestBody"]:
77-
continue
7875

79-
request_body = operation["requestBody"]
80-
if "content" not in request_body or not request_body["content"]:
81-
continue
76+
def _get_existing_schemas(schema_dict: dict[str, Any]) -> set[str]:
77+
"""Get the set of existing schema component names."""
78+
components = schema_dict.get("components")
79+
if components is None:
80+
return set()
81+
82+
schemas = components.get("schemas")
83+
if schemas is None:
84+
return set()
85+
86+
return set(schemas.keys())
8287

83-
content = request_body["content"]
84-
if "multipart/form-data" not in content:
85-
continue
8688

87-
multipart = content["multipart/form-data"]
89+
def _check_path_for_missing_components(
90+
path_item: dict[str, Any],
91+
path_url: str,
92+
existing_schemas: set[str],
93+
missing_components: list[tuple[str, str]]
94+
) -> None:
95+
"""Check a single path item for missing component references."""
96+
for _method, operation in path_item.items():
97+
if not isinstance(operation, dict):
98+
continue
99+
_check_operation_for_missing_components(operation, path_url, existing_schemas, missing_components)
88100

89-
# Get schema reference - could be in schema or schema_ (Pydantic v1/v2 difference)
90-
schema_ref = get_schema_ref(multipart)
91101

92-
if schema_ref and isinstance(schema_ref, str) and schema_ref.startswith("#/components/schemas/"):
93-
ref_name = schema_ref[len("#/components/schemas/") :]
94-
# Check if this component exists
95-
components = schema_dict.get("components", {})
96-
schemas = components.get("schemas", {})
102+
def _check_operation_for_missing_components(
103+
operation: dict[str, Any],
104+
path_url: str,
105+
existing_schemas: set[str],
106+
missing_components: list[tuple[str, str]]
107+
) -> None:
108+
"""Check a single operation for missing component references."""
109+
multipart_schema = _extract_multipart_schema(operation)
110+
if not multipart_schema:
111+
return
112+
113+
schema_ref = get_schema_ref(multipart_schema)
114+
ref_name = _extract_component_name(schema_ref)
115+
116+
if ref_name and ref_name not in existing_schemas:
117+
missing_components.append((ref_name, path_url))
97118

98-
if ref_name not in schemas:
99-
missing_components.append((ref_name, path_url))
100119

101-
return missing_components
120+
def _extract_multipart_schema(operation: dict[str, Any]) -> dict[str, Any] | None:
121+
"""Extract multipart/form-data schema from operation, if it exists."""
122+
if "requestBody" not in operation or not operation["requestBody"]:
123+
return None
124+
125+
request_body = operation["requestBody"]
126+
if "content" not in request_body or not request_body["content"]:
127+
return None
128+
129+
content = request_body["content"]
130+
if "multipart/form-data" not in content:
131+
return None
132+
133+
return content["multipart/form-data"]
134+
135+
136+
def _extract_component_name(schema_ref: str | None) -> str | None:
137+
"""Extract component name from schema reference."""
138+
if not schema_ref or not isinstance(schema_ref, str):
139+
return None
140+
141+
if not schema_ref.startswith("#/components/schemas/"):
142+
return None
143+
144+
return schema_ref[len("#/components/schemas/"):]
102145

103146

104147
def get_schema_ref(multipart: dict[str, Any]) -> str | None:

0 commit comments

Comments
 (0)