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.
Files changed (24) hide show
  1. {specforge_api-0.1.4 → specforge_api-0.1.6}/.gitignore +8 -0
  2. {specforge_api-0.1.4 → specforge_api-0.1.6}/PKG-INFO +3 -3
  3. {specforge_api-0.1.4 → specforge_api-0.1.6}/pyproject.toml +3 -3
  4. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/__init__.py +1 -1
  5. specforge_api-0.1.6/src/specforge/adapters/fastapi.py +148 -0
  6. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/adapters/flask.py +22 -13
  7. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/assets/index.html +164 -134
  8. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/cli/main.py +88 -5
  9. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/config.py +2 -0
  10. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/parser/openapi3.py +2 -0
  11. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/exporters/pytest_export.py +26 -16
  12. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/invariants.py +50 -8
  13. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/planner.py +33 -1
  14. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/utils/html_injector.py +15 -1
  15. specforge_api-0.1.4/src/specforge/adapters/fastapi.py +0 -101
  16. {specforge_api-0.1.4 → specforge_api-0.1.6}/LICENSE +0 -0
  17. {specforge_api-0.1.4 → specforge_api-0.1.6}/README.md +0 -0
  18. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/parser/models.py +0 -0
  19. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/parser/resolver.py +0 -0
  20. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
  21. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
  22. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
  23. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
  24. {specforge_api-0.1.4 → specforge_api-0.1.6}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
@@ -38,3 +38,11 @@ pnpm-debug.log*
38
38
  *.swo
39
39
  .DS_Store
40
40
  Thumbs.db
41
+
42
+ # Environment & Secrets (SEC-008)
43
+ .env
44
+ .env.*
45
+ *.env
46
+ *.key
47
+ *.pem
48
+ secrets/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: specforge-api
3
- Version: 0.1.4
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.0.0; extra == 'all'
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.0.0; extra == 'flask'
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.4"
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.0.0"]
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.0.0",
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.4"
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
- @bp.route("/specforge/api/plan", methods=["GET"])
64
- def specforge_plan() -> Any:
65
- if not self.spec_dict:
66
- return jsonify({"error": "SpecForge Flask adapter requires spec_dict for test planning."}), 400
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
- parser = OpenAPIParser(self.spec_dict)
69
- spec_model = parser.parse()
77
+ parser = OpenAPIParser(self.spec_dict)
78
+ spec_model = parser.parse()
70
79
 
71
- plans = []
72
- for op in spec_model.operations:
73
- plan = TestPlanner.plan_operation_tests(op)
74
- plans.append(plan.to_dict())
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
- return jsonify({"plans": plans})
85
+ return jsonify({"plans": plans})
77
86
 
78
87
  self.app.register_blueprint(bp)