Download tests/contract/test_openapi_validation.py from techprotrade/annator-command-center: direct link, hf CLI and curl.
- Browser
- Download file 14.7 kB
-
https://huggingface.co/spaces/techprotrade/annator-command-center/resolve/main/tests/contract/test_openapi_validation.py
- Command line
-
hf download hf://spaces/techprotrade/annator-command-center/tests/contract/test_openapi_validation.py
-
curl -L -o test_openapi_validation.py https://huggingface.co/spaces/techprotrade/annator-command-center/resolve/main/tests/contract/test_openapi_validation.py
14.7 kB
| """OpenAPI schema validation tests for comprehensive API documentation. | |
| Validates that the FastAPI application's OpenAPI schema is well-structured, | |
| complete, and consistent. Ensures all endpoints are properly documented with | |
| request/response schemas, tags, summaries, and security schemes. | |
| Test coverage: | |
| - Schema structure (OpenAPI version, info, paths, components) | |
| - Endpoint documentation (tags, summaries, responses, request bodies) | |
| - Schema consistency (refs resolve, components reused) | |
| - Schema coverage (documented routes match actual routes) | |
| """ | |
| import pytest | |
| from main_api_app import app | |
| class TestOpenAPISchemaStructure: | |
| """Tests for basic OpenAPI schema structure and required sections.""" | |
| def test_openapi_schema_version(self): | |
| """Test that schema uses OpenAPI 3.x specification.""" | |
| schema = app.openapi() | |
| assert "openapi" in schema, "Schema missing 'openapi' version field" | |
| assert schema["openapi"].startswith("3."), f"Expected OpenAPI 3.x, got {schema['openapi']}" | |
| def test_schema_has_info_section(self): | |
| """Test that schema contains required info section.""" | |
| schema = app.openapi() | |
| assert "info" in schema, "Schema missing 'info' section" | |
| info = schema["info"] | |
| assert "title" in info, "Info section missing 'title'" | |
| assert "version" in info, "Info section missing 'version'" | |
| assert isinstance(info.get("description"), str), "Info 'description' should be a string" | |
| def test_schema_has_paths_section(self): | |
| """Test that schema contains paths section with routes.""" | |
| schema = app.openapi() | |
| assert "paths" in schema, "Schema missing 'paths' section" | |
| assert isinstance(schema["paths"], dict), "Paths should be a dictionary" | |
| assert len(schema["paths"]) > 0, "Paths section should contain at least one route" | |
| def test_schema_has_components_section(self): | |
| """Test that schema defines components/schemas for models.""" | |
| schema = app.openapi() | |
| # OpenAPI 3.0 uses 'components', 3.1 uses 'components' (backward compatible) | |
| has_components = "components" in schema or "definitions" in schema | |
| assert has_components, "Schema should have 'components' or 'definitions' section" | |
| # Check for schemas | |
| components = schema.get("components", {}) or schema.get("definitions", {}) | |
| if "schemas" in components: | |
| assert len(components["schemas"]) > 0, "Should define at least one schema model" | |
| class TestEndpointDocumentation: | |
| """Tests for endpoint documentation completeness.""" | |
| def test_all_endpoints_have_tags(self): | |
| """Test that each documented path has at least one tag.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| undoc_tagged = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: | |
| tags = operation.get("tags", []) | |
| if not tags: | |
| undoc_tagged.append(f"{method.upper()} {path}") | |
| # Allow some endpoints to not have tags (e.g., health checks) | |
| # But most should have them for organization | |
| if undoc_tagged: | |
| # Log but don't fail - tags are recommended but not required | |
| pass | |
| def test_all_endpoints_have_summary(self): | |
| """Test that operations have descriptive summaries.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| undocumented = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: | |
| summary = operation.get("summary") or operation.get("description") | |
| if not summary: | |
| undocumented.append(f"{method.upper()} {path}") | |
| # Allow some endpoints without summaries | |
| # But most should have them for API documentation | |
| assert len(undocumented) < len(paths) * 0.5, "Too many endpoints missing summaries" | |
| def test_responses_documented(self): | |
| """Test that operations define at least one response schema.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| undocumented = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: | |
| responses = operation.get("responses") | |
| if not responses or len(responses) == 0: | |
| undocumented.append(f"{method.upper()} {path}") | |
| assert len(undocumented) == 0, f"Operations without responses: {undocumented}" | |
| def test_request_bodies_documented(self): | |
| """Test that POST/PUT operations define request schemas.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| undocumented = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["post", "put", "patch"]: | |
| # Check for request body or parameters | |
| has_body = "requestBody" in operation | |
| has_params = operation.get("parameters") | |
| if not has_body and not has_params: | |
| undocumented.append(f"{method.upper()} {path}") | |
| # Allow some POST/PUT without body (e.g., query params only) | |
| # But flag for review | |
| if undocumented: | |
| pass # Log for review | |
| class TestSchemaConsistency: | |
| """Tests for schema consistency and validity.""" | |
| def test_response_schemas_valid(self): | |
| """Test that response schema references resolve to valid components.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| components = schema.get("components", {}).get("schemas", {}) | |
| invalid_refs = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: | |
| responses = operation.get("responses", {}) | |
| for status, response in responses.items(): | |
| # Check for schema references | |
| content = response.get("content", {}) | |
| for content_type, content_schema in content.items(): | |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") | |
| if schema_ref: | |
| # Extract component name (e.g., "#/components/schemas/User") | |
| if schema_ref.startswith("#/components/schemas/"): | |
| component_name = schema_ref.split("/")[-1] | |
| if component_name not in components: | |
| invalid_refs.append(f"{method.upper()} {path} -> {component_name}") | |
| assert len(invalid_refs) == 0, f"Invalid schema references: {invalid_refs}" | |
| def test_request_schemas_valid(self): | |
| """Test that request schema references resolve to valid components.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| components = schema.get("components", {}).get("schemas", {}) | |
| invalid_refs = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["post", "put", "patch"]: | |
| request_body = operation.get("requestBody", {}) | |
| content = request_body.get("content", {}) | |
| for content_type, content_schema in content.items(): | |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") | |
| if schema_ref: | |
| if schema_ref.startswith("#/components/schemas/"): | |
| component_name = schema_ref.split("/")[-1] | |
| if component_name not in components: | |
| invalid_refs.append(f"{method.upper()} {path} -> {component_name}") | |
| assert len(invalid_refs) == 0, f"Invalid schema references: {invalid_refs}" | |
| def test_schema_reuse_consistent(self): | |
| """Test that common response patterns use consistent schemas.""" | |
| schema = app.openapi() | |
| components = schema.get("components", {}).get("schemas", {}) | |
| # Check for common schema patterns | |
| # These should be defined as reusable components | |
| expected_common_schemas = [ | |
| # "SuccessResponse", # Common success wrapper | |
| # "ErrorResponse", # Common error wrapper | |
| ] | |
| # This is a soft check - we want schema reuse but don't require specific schemas | |
| # Just verify that schemas are being reused (multiple refs to same component) | |
| schema_refs = {} | |
| paths = schema.get("paths", {}) | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: | |
| # Count request schema refs | |
| request_body = operation.get("requestBody", {}) | |
| content = request_body.get("content", {}) | |
| for content_schema in content.values(): | |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") | |
| if schema_ref: | |
| schema_refs[schema_ref] = schema_refs.get(schema_ref, 0) + 1 | |
| # Count response schema refs | |
| responses = operation.get("responses", {}) | |
| for response in responses.values(): | |
| content = response.get("content", {}) | |
| for content_schema in content.values(): | |
| schema_ref = content_schema.get("schema", {}).get("$ref", "") | |
| if schema_ref: | |
| schema_refs[schema_ref] = schema_refs.get(schema_ref, 0) + 1 | |
| # Check that at least some schemas are reused (referenced multiple times) | |
| reused_schemas = [ref for ref, count in schema_refs.items() if count > 1] | |
| # Don't fail if no reuse, just log for improvement | |
| # assert len(reused_schemas) > 0, "No schemas are being reused across endpoints" | |
| def test_security_schemes_defined(self): | |
| """Test that authentication methods are documented in security schemes.""" | |
| schema = app.openapi() | |
| components = schema.get("components", {}) | |
| security_schemes = components.get("securitySchemes", {}) | |
| # Check if security schemes are defined | |
| # Not required, but recommended for APIs with authentication | |
| if security_schemes: | |
| # Validate scheme structure | |
| for scheme_name, scheme in security_schemes.items(): | |
| assert "type" in scheme, f"Security scheme {scheme_name} missing 'type'" | |
| valid_types = ["apiKey", "http", "oauth2", "openIdConnect", "mutualTLS"] | |
| assert scheme["type"] in valid_types, f"Invalid scheme type: {scheme['type']}" | |
| class TestSchemaCoverage: | |
| """Tests for coverage between documented and actual routes.""" | |
| def test_documented_routes_match_actual(self): | |
| """Test that OpenAPI schema documents all actual FastAPI routes.""" | |
| schema = app.openapi() | |
| documented_paths = set(schema.get("paths", {}).keys()) | |
| # Get actual routes from FastAPI app | |
| actual_routes = set() | |
| for route in app.routes: | |
| if hasattr(route, "path") and hasattr(route, "methods"): | |
| # Include only HTTP routes (exclude websocket, etc.) | |
| if route.methods and None not in route.methods: | |
| actual_routes.add(route.path) | |
| # Normalize paths (remove path parameters for comparison) | |
| def normalize_path(path): | |
| import re | |
| return re.sub(r'\{[^}]+\}', ':param', path) | |
| documented_normalized = {normalize_path(p) for p in documented_paths} | |
| actual_normalized = {normalize_path(p) for p in actual_routes} | |
| # Find actual routes not in docs | |
| undocumented = actual_normalized - documented_normalized | |
| # Allow some undocumented routes (internal endpoints, etc.) | |
| # But flag significant gaps | |
| if len(undocumented) > 10: | |
| # Too many undocumented routes | |
| pass # Log for review | |
| def test_actual_routes_match_documented(self): | |
| """Test that all documented routes actually exist in FastAPI app.""" | |
| schema = app.openapi() | |
| documented_paths = set(schema.get("paths", {}).keys()) | |
| # Get actual routes from FastAPI app | |
| actual_routes = set() | |
| for route in app.routes: | |
| if hasattr(route, "path"): | |
| actual_routes.add(route.path) | |
| # Find documented routes not in actual | |
| missing = documented_paths - actual_routes | |
| assert len(missing) == 0, f"Documented routes not found in app: {missing}" | |
| def test_deprecated_routes_marked(self): | |
| """Test that deprecated status is documented in schema.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| deprecated_endpoints = [] | |
| for path, path_item in paths.items(): | |
| for method, operation in path_item.items(): | |
| if method.lower() in ["get", "post", "put", "delete", "patch"]: | |
| if operation.get("deprecated", False): | |
| deprecated_endpoints.append(f"{method.upper()} {path}") | |
| # Don't fail - just document deprecated endpoints | |
| # assert len(deprecated_endpoints) == 0 or all_marked, "Deprecated endpoints should be marked" | |
| def test_path_parameter_consistency(self): | |
| """Test that path parameters use consistent naming.""" | |
| schema = app.openapi() | |
| paths = schema.get("paths", {}) | |
| inconsistent = [] | |
| for path, path_item in paths.items(): | |
| # Extract path parameters | |
| import re | |
| params = re.findall(r'\{([^}]+)\}', path) | |
| # Check parameter naming (should be snake_case) | |
| for param in params: | |
| if not param.replace("_", "").isalnum(): | |
| inconsistent.append(f"{path} has invalid parameter: {param}") | |
| assert len(inconsistent) == 0, f"Path parameters with inconsistent naming: {inconsistent}" | |