specforge-api 0.1.4__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {specforge_api-0.1.4 → specforge_api-0.1.6}/.gitignore +8 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/PKG-INFO +3 -3
- {specforge_api-0.1.4 → specforge_api-0.1.6}/pyproject.toml +3 -3
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/__init__.py +1 -1
- specforge_api-0.1.6/src/specforge/adapters/fastapi.py +148 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/adapters/flask.py +22 -13
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/assets/index.html +164 -134
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/cli/main.py +88 -5
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/config.py +2 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/parser/openapi3.py +2 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/exporters/pytest_export.py +26 -16
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/invariants.py +50 -8
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/planner.py +33 -1
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/utils/html_injector.py +15 -1
- specforge_api-0.1.4/src/specforge/adapters/fastapi.py +0 -101
- {specforge_api-0.1.4 → specforge_api-0.1.6}/LICENSE +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/README.md +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/parser/models.py +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/parser/resolver.py +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
- {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: specforge-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: The interactive API client and automated contract testing engine for Python.
|
|
5
5
|
Project-URL: Homepage, https://github.com/specforge-api/specforge
|
|
6
6
|
Project-URL: Documentation, https://specforge-api.github.io/specforge/
|
|
@@ -47,7 +47,7 @@ Requires-Dist: pydantic>=2.0.0
|
|
|
47
47
|
Requires-Dist: pyyaml>=6.0
|
|
48
48
|
Provides-Extra: all
|
|
49
49
|
Requires-Dist: fastapi>=0.100.0; extra == 'all'
|
|
50
|
-
Requires-Dist: flask>=2.
|
|
50
|
+
Requires-Dist: flask>=2.3.0; extra == 'all'
|
|
51
51
|
Requires-Dist: rich>=13.0.0; extra == 'all'
|
|
52
52
|
Requires-Dist: typer>=0.9.0; extra == 'all'
|
|
53
53
|
Provides-Extra: cli
|
|
@@ -66,7 +66,7 @@ Requires-Dist: pymdown-extensions>=10.7; extra == 'docs'
|
|
|
66
66
|
Provides-Extra: fastapi
|
|
67
67
|
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
|
|
68
68
|
Provides-Extra: flask
|
|
69
|
-
Requires-Dist: flask>=2.
|
|
69
|
+
Requires-Dist: flask>=2.3.0; extra == 'flask'
|
|
70
70
|
Description-Content-Type: text/markdown
|
|
71
71
|
|
|
72
72
|
# SpecForge ⚡
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specforge-api"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "The interactive API client and automated contract testing engine for Python."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -43,11 +43,11 @@ dependencies = [
|
|
|
43
43
|
|
|
44
44
|
[project.optional-dependencies]
|
|
45
45
|
fastapi = ["fastapi>=0.100.0"]
|
|
46
|
-
flask = ["flask>=2.
|
|
46
|
+
flask = ["flask>=2.3.0"]
|
|
47
47
|
cli = ["typer>=0.9.0", "rich>=13.0.0"]
|
|
48
48
|
all = [
|
|
49
49
|
"fastapi>=0.100.0",
|
|
50
|
-
"flask>=2.
|
|
50
|
+
"flask>=2.3.0",
|
|
51
51
|
"typer>=0.9.0",
|
|
52
52
|
"rich>=13.0.0"
|
|
53
53
|
]
|
|
@@ -5,7 +5,7 @@ from specforge.parser.openapi3 import OpenAPIParser
|
|
|
5
5
|
from specforge.testing.exporters.pytest_export import PytestExporter
|
|
6
6
|
from specforge.testing.planner import TestPlanner
|
|
7
7
|
|
|
8
|
-
__version__ = "0.1.
|
|
8
|
+
__version__ = "0.1.6"
|
|
9
9
|
|
|
10
10
|
# Conditionally import FastAPI adapter if available
|
|
11
11
|
try:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""FastAPI adapter for SpecForge interactive documentation and contract testing."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from fastapi import FastAPI, Request, Response # type: ignore
|
|
8
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse # type: ignore
|
|
9
|
+
except ImportError:
|
|
10
|
+
FastAPI = None # type: ignore
|
|
11
|
+
|
|
12
|
+
from specforge.config import SpecForgeConfig
|
|
13
|
+
from specforge.parser.openapi3 import OpenAPIParser
|
|
14
|
+
from specforge.testing.exporters.pytest_export import PytestExporter
|
|
15
|
+
from specforge.testing.planner import TestPlanner
|
|
16
|
+
from specforge.utils.html_injector import render_html
|
|
17
|
+
|
|
18
|
+
_SECURITY_HEADERS = {
|
|
19
|
+
"X-Content-Type-Options": "nosniff",
|
|
20
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
21
|
+
"Referrer-Policy": "no-referrer",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SpecForge:
|
|
26
|
+
"""Mounts the SpecForge interactive client and boundary test suite onto a FastAPI application."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
app: Any,
|
|
31
|
+
path: str = "/docs",
|
|
32
|
+
title: str = "SpecForge API Studio",
|
|
33
|
+
spec_url: Optional[str] = None,
|
|
34
|
+
theme: str = "dark",
|
|
35
|
+
enable_tests: bool = True,
|
|
36
|
+
disable_default_docs: bool = True,
|
|
37
|
+
enable_api_endpoints: bool = True,
|
|
38
|
+
dependencies: Optional[Sequence[Any]] = None,
|
|
39
|
+
api_dependencies: Optional[Sequence[Any]] = None,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
):
|
|
42
|
+
if FastAPI is None:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"FastAPI is not installed. Install it with `pip install specforge[fastapi]`."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.app = app
|
|
48
|
+
self.path = path
|
|
49
|
+
self.spec_url = spec_url or getattr(app, "openapi_url", "/openapi.json")
|
|
50
|
+
self.dependencies = list(dependencies) if dependencies else []
|
|
51
|
+
self.api_dependencies = list(api_dependencies) if api_dependencies is not None else self.dependencies
|
|
52
|
+
|
|
53
|
+
self.config = SpecForgeConfig(
|
|
54
|
+
title=title,
|
|
55
|
+
path=path,
|
|
56
|
+
spec_url=self.spec_url,
|
|
57
|
+
theme=theme, # type: ignore
|
|
58
|
+
enable_tests=enable_tests,
|
|
59
|
+
enable_api_endpoints=enable_api_endpoints,
|
|
60
|
+
**kwargs,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if disable_default_docs:
|
|
64
|
+
self._remove_conflicting_docs_routes()
|
|
65
|
+
|
|
66
|
+
self._register_routes()
|
|
67
|
+
|
|
68
|
+
def _remove_conflicting_docs_routes(self) -> None:
|
|
69
|
+
"""Remove FastAPI's pre-registered docs routes that conflict with SpecForge."""
|
|
70
|
+
default_docs_url = getattr(self.app, "docs_url", None)
|
|
71
|
+
if default_docs_url:
|
|
72
|
+
self.app.docs_url = None
|
|
73
|
+
if getattr(self.app, "redoc_url", None) == self.path:
|
|
74
|
+
self.app.redoc_url = None
|
|
75
|
+
|
|
76
|
+
router = getattr(self.app, "router", None)
|
|
77
|
+
if router is not None and hasattr(router, "routes"):
|
|
78
|
+
router.routes = [
|
|
79
|
+
route
|
|
80
|
+
for route in router.routes
|
|
81
|
+
if not (
|
|
82
|
+
"GET" in (getattr(route, "methods", None) or set())
|
|
83
|
+
and (
|
|
84
|
+
getattr(route, "path", None) == self.path
|
|
85
|
+
or (
|
|
86
|
+
default_docs_url is not None
|
|
87
|
+
and getattr(route, "path", None) == default_docs_url
|
|
88
|
+
and getattr(route, "name", None) in ("swagger_ui_html", "swagger_ui_redirect")
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def _register_routes(self) -> None:
|
|
95
|
+
"""Register the documentation UI and test plan endpoints on the FastAPI app."""
|
|
96
|
+
|
|
97
|
+
@self.app.get(
|
|
98
|
+
self.path,
|
|
99
|
+
response_class=HTMLResponse,
|
|
100
|
+
include_in_schema=False,
|
|
101
|
+
dependencies=self.dependencies,
|
|
102
|
+
)
|
|
103
|
+
async def specforge_docs_endpoint(request: Request) -> Response:
|
|
104
|
+
html = render_html(self.config)
|
|
105
|
+
return HTMLResponse(content=html, headers=_SECURITY_HEADERS)
|
|
106
|
+
|
|
107
|
+
if self.config.enable_api_endpoints:
|
|
108
|
+
@self.app.get(
|
|
109
|
+
"/specforge/api/plan",
|
|
110
|
+
response_class=JSONResponse,
|
|
111
|
+
include_in_schema=False,
|
|
112
|
+
dependencies=self.api_dependencies,
|
|
113
|
+
)
|
|
114
|
+
async def specforge_test_plan_endpoint(request: Request) -> Response:
|
|
115
|
+
# Generate or retrieve active OpenAPI specification
|
|
116
|
+
raw_spec: dict[str, Any] = self.app.openapi()
|
|
117
|
+
parser = OpenAPIParser(raw_spec)
|
|
118
|
+
spec_model = parser.parse()
|
|
119
|
+
|
|
120
|
+
plans = []
|
|
121
|
+
for op in spec_model.operations:
|
|
122
|
+
plan = TestPlanner.plan_operation_tests(op)
|
|
123
|
+
plans.append(plan.to_dict())
|
|
124
|
+
|
|
125
|
+
return JSONResponse(content={"plans": plans})
|
|
126
|
+
|
|
127
|
+
@self.app.get(
|
|
128
|
+
"/specforge/api/export-pytest",
|
|
129
|
+
response_class=PlainTextResponse,
|
|
130
|
+
include_in_schema=False,
|
|
131
|
+
dependencies=self.api_dependencies,
|
|
132
|
+
)
|
|
133
|
+
async def specforge_export_pytest_endpoint(request: Request) -> Response:
|
|
134
|
+
raw_spec: dict[str, Any] = self.app.openapi()
|
|
135
|
+
parser = OpenAPIParser(raw_spec)
|
|
136
|
+
spec_model = parser.parse()
|
|
137
|
+
|
|
138
|
+
all_test_cases = []
|
|
139
|
+
for op in spec_model.operations:
|
|
140
|
+
plan = TestPlanner.plan_operation_tests(op)
|
|
141
|
+
all_test_cases.extend(plan.test_cases)
|
|
142
|
+
|
|
143
|
+
base_url = str(request.base_url).rstrip("/")
|
|
144
|
+
code = PytestExporter.export(all_test_cases, base_url=base_url)
|
|
145
|
+
return PlainTextResponse(
|
|
146
|
+
content=code,
|
|
147
|
+
headers={"Content-Disposition": 'attachment; filename="test_api_contracts.py"'},
|
|
148
|
+
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from typing import Any, Optional
|
|
4
4
|
|
|
5
5
|
try:
|
|
6
|
-
from flask import (
|
|
6
|
+
from flask import ( # type: ignore
|
|
7
7
|
Blueprint,
|
|
8
8
|
Flask,
|
|
9
9
|
Response,
|
|
@@ -17,6 +17,12 @@ from specforge.parser.openapi3 import OpenAPIParser
|
|
|
17
17
|
from specforge.testing.planner import TestPlanner
|
|
18
18
|
from specforge.utils.html_injector import render_html
|
|
19
19
|
|
|
20
|
+
_SECURITY_HEADERS = {
|
|
21
|
+
"X-Content-Type-Options": "nosniff",
|
|
22
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
23
|
+
"Referrer-Policy": "no-referrer",
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
class SpecForge:
|
|
22
28
|
"""Mounts the SpecForge interactive client and boundary test suite onto a Flask application."""
|
|
@@ -29,6 +35,7 @@ class SpecForge:
|
|
|
29
35
|
title: str = "SpecForge API Studio",
|
|
30
36
|
theme: str = "dark",
|
|
31
37
|
enable_tests: bool = True,
|
|
38
|
+
enable_api_endpoints: bool = True,
|
|
32
39
|
spec_dict: Optional[dict[str, Any]] = None,
|
|
33
40
|
**kwargs: Any,
|
|
34
41
|
):
|
|
@@ -46,6 +53,7 @@ class SpecForge:
|
|
|
46
53
|
spec_url=spec_url,
|
|
47
54
|
theme=theme, # type: ignore
|
|
48
55
|
enable_tests=enable_tests,
|
|
56
|
+
enable_api_endpoints=enable_api_endpoints,
|
|
49
57
|
**kwargs,
|
|
50
58
|
)
|
|
51
59
|
|
|
@@ -58,21 +66,22 @@ class SpecForge:
|
|
|
58
66
|
@bp.route(self.path, methods=["GET"])
|
|
59
67
|
def specforge_docs() -> Any:
|
|
60
68
|
html = render_html(self.config, self.spec_dict)
|
|
61
|
-
return Response(html, mimetype="text/html")
|
|
69
|
+
return Response(html, mimetype="text/html", headers=_SECURITY_HEADERS)
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
if self.config.enable_api_endpoints:
|
|
72
|
+
@bp.route("/specforge/api/plan", methods=["GET"])
|
|
73
|
+
def specforge_plan() -> Any:
|
|
74
|
+
if not self.spec_dict:
|
|
75
|
+
return jsonify({"error": "SpecForge Flask adapter requires spec_dict for test planning."}), 400
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
parser = OpenAPIParser(self.spec_dict)
|
|
78
|
+
spec_model = parser.parse()
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
plans = []
|
|
81
|
+
for op in spec_model.operations:
|
|
82
|
+
plan = TestPlanner.plan_operation_tests(op)
|
|
83
|
+
plans.append(plan.to_dict())
|
|
75
84
|
|
|
76
|
-
|
|
85
|
+
return jsonify({"plans": plans})
|
|
77
86
|
|
|
78
87
|
self.app.register_blueprint(bp)
|