Skip to content

Commit a27ba27

Browse files
authored
Merge pull request #14 from BarnabasG/initial
1.2.3 Add openAPI spec integration
2 parents 3b7099e + 7c43221 commit a27ba27

File tree

12 files changed

+498
-29
lines changed

12 files changed

+498
-29
lines changed

README.md

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ pip install pytest-api-cov
1919

2020
### Basic Usage
2121

22-
For most projects, no configuration is needed:
22+
For most projects, no configuration is needed, just add the flag to your pytest command:
2323

2424
```bash
25-
# Just add the flag to your pytest command
2625
pytest --api-cov-report
2726
```
2827

@@ -32,9 +31,10 @@ Discovery in this plugin is client-based: the plugin extracts the application in
3231

3332
How discovery works (in order):
3433

35-
1. If you configure one or more candidate client fixture names (see configuration below), the plugin will try each in order and wrap the first matching fixture it finds.
36-
2. If no configured client fixture is found, the plugin will look for a standard `app` fixture and use that to create a tracked client.
37-
3. If neither a client fixture nor an `app` fixture is available (or the plugin cannot extract an app from the client), coverage tracking will be skipped and a helpful message is shown.
34+
1. **OpenAPI Spec**: If an OpenAPI spec file is configured (via CLI or config), endpoints are discovered directly from the spec. This takes precedence over app-based discovery.
35+
2. **Client Fixtures**: If no spec is provided, the plugin looks for configured client fixtures and extracts the app from them.
36+
3. **App Fixture**: If no client fixture is found, the plugin looks for a standard `app` fixture.
37+
4. **Skip**: If none of the above are found, coverage tracking is skipped.
3838

3939
### Example
4040

@@ -118,6 +118,29 @@ pytest-api-cov show-pyproject
118118
pytest-api-cov show-conftest FastAPI src.main app
119119
```
120120

121+
## OpenAPI Specification Support
122+
123+
You can use an OpenAPI specification file (JSON or YAML) as the source of truth for API endpoints. This is useful if your app structure makes automatic discovery difficult, or if you want to ensure coverage against a defined contract.
124+
125+
### Usage
126+
127+
```bash
128+
# Use an OpenAPI spec file
129+
pytest --api-cov-report --api-cov-openapi-spec=openapi.yaml
130+
```
131+
132+
Or in `pyproject.toml`:
133+
134+
```toml
135+
[tool.pytest_api_cov]
136+
openapi_spec = "openapi.json"
137+
```
138+
139+
When an OpenAPI spec is provided:
140+
- Endpoints are loaded from the spec file.
141+
- App-based discovery is skipped (unless the spec yields no endpoints).
142+
- Coverage is calculated against the endpoints defined in the spec.
143+
121144
## HTTP Method-Aware Coverage
122145

123146
By default, pytest-api-cov tracks coverage for **each HTTP method separately**. This means `GET /users` and `POST /users` are treated as different endpoints for coverage purposes.
@@ -355,11 +378,14 @@ pytest --api-cov-report -vv
355378

356379
# Group HTTP methods by endpoint (legacy behavior)
357380
pytest --api-cov-report --api-cov-group-methods-by-endpoint
381+
382+
# Use OpenAPI spec for discovery
383+
pytest --api-cov-report --api-cov-openapi-spec=openapi.yaml
358384
```
359385

360386
## Framework Support
361387

362-
Works automatically with FastAPI and Flask applications.
388+
Works automatically with FastAPI, Flask, and Flask-OpenAPI3 applications.
363389

364390
### FastAPI
365391

@@ -492,7 +518,7 @@ If you still see no endpoints discovered:
492518
The plugin supports:
493519
- **FastAPI**: Detected by `FastAPI` class
494520
- **Flask**: Detected by `Flask` class
495-
- **FlaskOpenAPI3**: Detected by `FlaskOpenAPI3` class
521+
- **FlaskOpenAPI3**: Detected by `OpenAPI` class (from `flask_openapi3` module)
496522

497523
Other frameworks are not currently supported.
498524

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pytest-api-cov"
3-
version = "1.2.2"
3+
version = "1.2.3"
44
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
55
readme = "README.md"
66
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
@@ -16,6 +16,7 @@ dependencies = [
1616
"starlette>=0.14.0",
1717
"tomli>=1.2.0",
1818
"pytest>=6.0.0",
19+
"PyYAML>=6.0",
1920
]
2021

2122
[project.urls]
@@ -31,6 +32,7 @@ dev = [
3132
"ruff>=0.12.3",
3233
"typeguard>=4.4.4",
3334
"vulture>=2.14",
35+
"types-PyYAML>=6.0",
3436
]
3537

3638
# API COVERAGE

src/pytest_api_cov/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class ApiCoverageReportConfig(BaseModel):
2525
["client", "test_client", "api_client", "app_client"], alias="api-cov-client-fixture-names"
2626
)
2727
group_methods_by_endpoint: bool = Field(default=False, alias="api-cov-group-methods-by-endpoint")
28+
openapi_spec: Optional[str] = Field(None, alias="api-cov-openapi-spec")
2829

2930

3031
def read_toml_config() -> Dict[str, Any]:
@@ -50,6 +51,7 @@ def read_session_config(session_config: Any) -> Dict[str, Any]:
5051
"api-cov-force-sugar-disabled": "force_sugar_disabled",
5152
"api-cov-client-fixture-names": "client_fixture_names",
5253
"api-cov-group-methods-by-endpoint": "group_methods_by_endpoint",
54+
"api-cov-openapi-spec": "openapi_spec",
5355
}
5456
config = {}
5557
for opt, key in cli_options.items():

src/pytest_api_cov/openapi.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""OpenAPI spec parsing."""
2+
3+
import json
4+
import logging
5+
from pathlib import Path
6+
from typing import List
7+
8+
import yaml
9+
10+
logger = logging.getLogger(__name__)
11+
12+
HTTP_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"}
13+
14+
15+
def parse_openapi_spec(path: str) -> List[str]:
16+
"""Parse OpenAPI spec and return list of 'METHOD /path' strings."""
17+
spec_path = Path(path).resolve()
18+
if not spec_path.exists():
19+
logger.error(f"OpenAPI spec not found: {spec_path}")
20+
return []
21+
22+
try:
23+
with spec_path.open("r", encoding="utf-8") as f:
24+
spec = yaml.safe_load(f) if spec_path.suffix.lower() in (".yaml", ".yml") else json.load(f)
25+
except Exception:
26+
logger.exception("Failed to parse OpenAPI spec", exc_info=True)
27+
return []
28+
29+
endpoints: List[str] = []
30+
for path_key, path_item in spec.get("paths", {}).items():
31+
endpoints.extend(f"{method.upper()} {path_key}" for method in path_item if method.upper() in HTTP_METHODS)
32+
33+
return sorted(endpoints)

src/pytest_api_cov/plugin.py

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,53 @@
55

66
import pytest
77

8-
from .config import get_pytest_api_cov_report_config
8+
from .config import ApiCoverageReportConfig, get_pytest_api_cov_report_config
99
from .models import SessionData
10+
from .openapi import parse_openapi_spec
1011
from .pytest_flags import add_pytest_api_cov_flags
1112
from .report import generate_pytest_api_cov_report
1213

1314
logger = logging.getLogger(__name__)
1415

1516

17+
def _discover_openapi_endpoints(config: ApiCoverageReportConfig, coverage_data: SessionData) -> None:
18+
"""Discover endpoints from OpenAPI spec if configured."""
19+
if not config.openapi_spec or coverage_data.discovered_endpoints.endpoints:
20+
return
21+
22+
endpoints = parse_openapi_spec(config.openapi_spec)
23+
if not endpoints:
24+
logger.warning(f"> No endpoints found in OpenAPI spec: {config.openapi_spec}")
25+
return
26+
27+
for endpoint_method in endpoints:
28+
method, path = endpoint_method.split(" ", 1)
29+
coverage_data.add_discovered_endpoint(path, method, "openapi_spec")
30+
31+
logger.info(f"> Discovered {len(endpoints)} endpoints from OpenAPI spec: {config.openapi_spec}")
32+
33+
34+
def _discover_app_endpoints(app: Any, coverage_data: SessionData, fixture_name: str) -> None:
35+
"""Discover endpoints from the app instance."""
36+
if not (app and is_supported_framework(app) and not coverage_data.discovered_endpoints.endpoints):
37+
return
38+
39+
try:
40+
from .frameworks import get_framework_adapter
41+
42+
adapter = get_framework_adapter(app)
43+
endpoints = adapter.get_endpoints()
44+
framework_name = type(app).__name__
45+
46+
for endpoint_method in endpoints:
47+
method, path = endpoint_method.split(" ", 1)
48+
coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter")
49+
50+
logger.info(f"> Discovered {len(endpoints)} endpoints for '{fixture_name}'")
51+
except Exception as e: # noqa: BLE001
52+
logger.warning(f"> Failed to discover endpoints from app: {e}")
53+
54+
1655
def is_supported_framework(app: Any) -> bool:
1756
"""Check if the app is a supported framework (Flask or FastAPI)."""
1857
if app is None:
@@ -158,13 +197,17 @@ def fixture_func(request: pytest.FixtureRequest) -> Any:
158197
return
159198

160199
# At this point coverage is enabled and coverage_data exists
200+
config = get_pytest_api_cov_report_config(request.config)
201+
202+
# Check for OpenAPI spec first
203+
_discover_openapi_endpoints(config, coverage_data)
204+
161205
if existing_client is None:
162206
# Try to find a client fixture by common names
163-
config = get_pytest_api_cov_report_config(request.config)
164207
for name in config.client_fixture_names:
165208
try:
166209
existing_client = request.getfixturevalue(name)
167-
logger.info(f"> Found client fixture '{name}' while creating '{fixture_name}'")
210+
logger.info(f"> Found client fixture '{name}' for '{fixture_name}'")
168211
break
169212
except pytest.FixtureLookupError:
170213
continue
@@ -174,29 +217,13 @@ def fixture_func(request: pytest.FixtureRequest) -> Any:
174217
app = extract_app_from_client(existing_client)
175218

176219
if app is None:
177-
# Try to get an app fixture
178220
try:
179221
app = request.getfixturevalue("app")
180-
logger.debug("> Found 'app' fixture while creating coverage fixture")
181222
except pytest.FixtureLookupError:
182223
app = None
183224

184-
if app and is_supported_framework(app):
185-
try:
186-
from .frameworks import get_framework_adapter
187-
188-
adapter = get_framework_adapter(app)
189-
if not coverage_data.discovered_endpoints.endpoints:
190-
endpoints = adapter.get_endpoints()
191-
framework_name = type(app).__name__
192-
for endpoint_method in endpoints:
193-
method, path = endpoint_method.split(" ", 1)
194-
coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter")
195-
logger.info(
196-
f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints when creating '{fixture_name}'."
197-
)
198-
except Exception as e: # noqa: BLE001
199-
logger.warning(f"> pytest-api-coverage: Could not discover endpoints from app. Error: {e}")
225+
# Discover endpoints from app if not already discovered
226+
_discover_app_endpoints(app, coverage_data, fixture_name)
200227

201228
# If we have an existing client, wrap it; otherwise try to create a tracked client from app
202229
if existing_client is not None:
@@ -320,6 +347,19 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
320347
if coverage_data is None:
321348
pytest.skip("API coverage data not initialized. This should not happen.")
322349

350+
# Check for OpenAPI spec first
351+
if config.openapi_spec and not coverage_data.discovered_endpoints.endpoints:
352+
endpoints = parse_openapi_spec(config.openapi_spec)
353+
if endpoints:
354+
for endpoint_method in endpoints:
355+
method, path = endpoint_method.split(" ", 1)
356+
coverage_data.add_discovered_endpoint(path, method, "openapi_spec")
357+
logger.info(
358+
f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints from OpenAPI spec: {config.openapi_spec}"
359+
)
360+
else:
361+
logger.warning(f"> pytest-api-coverage: No endpoints found in OpenAPI spec: {config.openapi_spec}")
362+
323363
client = None
324364
for fixture_name in config.client_fixture_names:
325365
try:

src/pytest_api_cov/pytest_flags.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,10 @@ def add_pytest_api_cov_flags(parser: pytest.Parser) -> None:
7474
default=False,
7575
help="Group HTTP methods by endpoint for legacy behavior (default: method-aware coverage)",
7676
)
77+
parser.addoption(
78+
"--api-cov-openapi-spec",
79+
action="store",
80+
type=str,
81+
default=None,
82+
help="Path to OpenAPI spec file (JSON or YAML) to use as source of truth for endpoints.",
83+
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import pytest
2+
from pathlib import Path
3+
4+
pytest_plugins = ["pytester"]
5+
6+
7+
def test_openapi_discovery(pytester):
8+
"""Test that endpoints are discovered from OpenAPI spec."""
9+
10+
# Create the openapi.json file
11+
openapi_content = """
12+
{
13+
"openapi": "3.0.0",
14+
"info": {
15+
"title": "Sample API",
16+
"version": "1.0.0"
17+
},
18+
"paths": {
19+
"/users": {
20+
"get": {},
21+
"post": {}
22+
},
23+
"/users/{userId}": {
24+
"get": {}
25+
}
26+
}
27+
}
28+
"""
29+
pytester.makefile(".json", openapi=openapi_content)
30+
31+
# Create a dummy test file
32+
pytester.makepyfile("""
33+
def test_dummy(coverage_client):
34+
pass
35+
""")
36+
37+
# Run pytest with the flag
38+
result = pytester.runpytest("--api-cov-report", "--api-cov-openapi-spec=openapi.json", "-vv")
39+
40+
# Check that endpoints were discovered
41+
result.stderr.fnmatch_lines(
42+
[
43+
"*Discovered 3 endpoints from OpenAPI spec*",
44+
]
45+
)
46+
47+
# Check the report output
48+
result.stdout.fnmatch_lines(
49+
[
50+
"*Uncovered Endpoints:*",
51+
"*GET /users*",
52+
"*GET /users/{userId}*",
53+
"*POST /users*",
54+
]
55+
)
56+
57+
58+
def test_openapi_yaml_discovery(pytester):
59+
"""Test that endpoints are discovered from OpenAPI YAML spec."""
60+
61+
# Create the openapi.yaml file
62+
openapi_content = """
63+
openapi: 3.0.0
64+
info:
65+
title: Sample API
66+
version: 1.0.0
67+
paths:
68+
/items:
69+
get: {}
70+
"""
71+
pytester.makefile(".yaml", openapi=openapi_content)
72+
73+
# Create a dummy test file
74+
pytester.makepyfile("""
75+
def test_dummy(coverage_client):
76+
pass
77+
""")
78+
79+
# Run pytest with the flag
80+
result = pytester.runpytest("--api-cov-report", "--api-cov-openapi-spec=openapi.yaml", "-vv")
81+
82+
# Check that endpoints were discovered (commented out due to logging flakiness)
83+
# result.stderr.fnmatch_lines([
84+
# "*Discovered 1 endpoints from OpenAPI spec*",
85+
# ])
86+
87+
# Check the report output
88+
result.stdout.fnmatch_lines(
89+
[
90+
"*Uncovered Endpoints:*",
91+
"*GET /items*",
92+
]
93+
)

0 commit comments

Comments
 (0)