specforge-api 0.1.4__tar.gz → 0.1.5__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.5}/.gitignore +8 -0
  2. {specforge_api-0.1.4 → specforge_api-0.1.5}/PKG-INFO +3 -3
  3. {specforge_api-0.1.4 → specforge_api-0.1.5}/pyproject.toml +3 -3
  4. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/__init__.py +1 -1
  5. specforge_api-0.1.5/src/specforge/adapters/fastapi.py +116 -0
  6. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/adapters/flask.py +15 -12
  7. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/cli/main.py +74 -3
  8. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/config.py +2 -0
  9. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/exporters/pytest_export.py +8 -11
  10. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/invariants.py +7 -7
  11. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/utils/html_injector.py +15 -1
  12. specforge_api-0.1.4/src/specforge/adapters/fastapi.py +0 -101
  13. {specforge_api-0.1.4 → specforge_api-0.1.5}/LICENSE +0 -0
  14. {specforge_api-0.1.4 → specforge_api-0.1.5}/README.md +0 -0
  15. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/assets/index.html +0 -0
  16. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/parser/models.py +0 -0
  17. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/parser/openapi3.py +0 -0
  18. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/parser/resolver.py +0 -0
  19. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
  20. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
  21. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
  22. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
  23. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
  24. {specforge_api-0.1.4 → specforge_api-0.1.5}/src/specforge/testing/planner.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.5
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.5"
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.5"
9
9
 
10
10
  # Conditionally import FastAPI adapter if available
11
11
  try:
@@ -0,0 +1,116 @@
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
+
19
+ class SpecForge:
20
+ """Mounts the SpecForge interactive client and boundary test suite onto a FastAPI application."""
21
+
22
+ def __init__(
23
+ self,
24
+ app: Any,
25
+ path: str = "/docs",
26
+ title: str = "SpecForge API Studio",
27
+ spec_url: Optional[str] = None,
28
+ theme: str = "dark",
29
+ enable_tests: bool = True,
30
+ disable_default_docs: bool = False,
31
+ enable_api_endpoints: bool = True,
32
+ dependencies: Optional[Sequence[Any]] = None,
33
+ api_dependencies: Optional[Sequence[Any]] = None,
34
+ **kwargs: Any,
35
+ ):
36
+ if FastAPI is None:
37
+ raise ImportError(
38
+ "FastAPI is not installed. Install it with `pip install specforge[fastapi]`."
39
+ )
40
+
41
+ self.app = app
42
+ self.path = path
43
+ self.spec_url = spec_url or getattr(app, "openapi_url", "/openapi.json")
44
+ self.dependencies = list(dependencies) if dependencies else []
45
+ self.api_dependencies = list(api_dependencies) if api_dependencies is not None else self.dependencies
46
+
47
+ self.config = SpecForgeConfig(
48
+ title=title,
49
+ path=path,
50
+ spec_url=self.spec_url,
51
+ theme=theme, # type: ignore
52
+ enable_tests=enable_tests,
53
+ enable_api_endpoints=enable_api_endpoints,
54
+ **kwargs,
55
+ )
56
+
57
+ if disable_default_docs and hasattr(app, "docs_url"):
58
+ app.docs_url = None
59
+
60
+ self._register_routes()
61
+
62
+ def _register_routes(self) -> None:
63
+ """Register the documentation UI and test plan endpoints on the FastAPI app."""
64
+
65
+ @self.app.get(
66
+ self.path,
67
+ response_class=HTMLResponse,
68
+ include_in_schema=False,
69
+ dependencies=self.dependencies,
70
+ )
71
+ async def specforge_docs_endpoint(request: Request) -> Response:
72
+ html = render_html(self.config)
73
+ return HTMLResponse(content=html)
74
+
75
+ if self.config.enable_api_endpoints:
76
+ @self.app.get(
77
+ "/specforge/api/plan",
78
+ response_class=JSONResponse,
79
+ include_in_schema=False,
80
+ dependencies=self.api_dependencies,
81
+ )
82
+ async def specforge_test_plan_endpoint(request: Request) -> Response:
83
+ # Generate or retrieve active OpenAPI specification
84
+ raw_spec: dict[str, Any] = self.app.openapi()
85
+ parser = OpenAPIParser(raw_spec)
86
+ spec_model = parser.parse()
87
+
88
+ plans = []
89
+ for op in spec_model.operations:
90
+ plan = TestPlanner.plan_operation_tests(op)
91
+ plans.append(plan.to_dict())
92
+
93
+ return JSONResponse(content={"plans": plans})
94
+
95
+ @self.app.get(
96
+ "/specforge/api/export-pytest",
97
+ response_class=PlainTextResponse,
98
+ include_in_schema=False,
99
+ dependencies=self.api_dependencies,
100
+ )
101
+ async def specforge_export_pytest_endpoint(request: Request) -> Response:
102
+ raw_spec: dict[str, Any] = self.app.openapi()
103
+ parser = OpenAPIParser(raw_spec)
104
+ spec_model = parser.parse()
105
+
106
+ all_test_cases = []
107
+ for op in spec_model.operations:
108
+ plan = TestPlanner.plan_operation_tests(op)
109
+ all_test_cases.extend(plan.test_cases)
110
+
111
+ base_url = str(request.base_url).rstrip("/")
112
+ code = PytestExporter.export(all_test_cases, base_url=base_url)
113
+ return PlainTextResponse(
114
+ content=code,
115
+ headers={"Content-Disposition": 'attachment; filename="test_api_contracts.py"'},
116
+ )
@@ -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,
@@ -29,6 +29,7 @@ class SpecForge:
29
29
  title: str = "SpecForge API Studio",
30
30
  theme: str = "dark",
31
31
  enable_tests: bool = True,
32
+ enable_api_endpoints: bool = True,
32
33
  spec_dict: Optional[dict[str, Any]] = None,
33
34
  **kwargs: Any,
34
35
  ):
@@ -46,6 +47,7 @@ class SpecForge:
46
47
  spec_url=spec_url,
47
48
  theme=theme, # type: ignore
48
49
  enable_tests=enable_tests,
50
+ enable_api_endpoints=enable_api_endpoints,
49
51
  **kwargs,
50
52
  )
51
53
 
@@ -60,19 +62,20 @@ class SpecForge:
60
62
  html = render_html(self.config, self.spec_dict)
61
63
  return Response(html, mimetype="text/html")
62
64
 
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
65
+ if self.config.enable_api_endpoints:
66
+ @bp.route("/specforge/api/plan", methods=["GET"])
67
+ def specforge_plan() -> Any:
68
+ if not self.spec_dict:
69
+ return jsonify({"error": "SpecForge Flask adapter requires spec_dict for test planning."}), 400
67
70
 
68
- parser = OpenAPIParser(self.spec_dict)
69
- spec_model = parser.parse()
71
+ parser = OpenAPIParser(self.spec_dict)
72
+ spec_model = parser.parse()
70
73
 
71
- plans = []
72
- for op in spec_model.operations:
73
- plan = TestPlanner.plan_operation_tests(op)
74
- plans.append(plan.to_dict())
74
+ plans = []
75
+ for op in spec_model.operations:
76
+ plan = TestPlanner.plan_operation_tests(op)
77
+ plans.append(plan.to_dict())
75
78
 
76
- return jsonify({"plans": plans})
79
+ return jsonify({"plans": plans})
77
80
 
78
81
  self.app.register_blueprint(bp)
@@ -1,9 +1,12 @@
1
1
  """Command-line interface for SpecForge."""
2
2
 
3
3
  import argparse
4
+ import ipaddress
4
5
  import json
6
+ import socket
5
7
  import sys
6
8
  import time
9
+ import urllib.parse
7
10
  import urllib.request
8
11
  from pathlib import Path
9
12
  from typing import Any
@@ -15,17 +18,82 @@ from specforge.testing.exporters.pytest_export import PytestExporter
15
18
  from specforge.testing.invariants import TestStatus, evaluate_test_result
16
19
  from specforge.testing.planner import TestPlanner
17
20
 
21
+ MAX_SPEC_SIZE = 10 * 1024 * 1024 # 10 MB maximum specification size
22
+ DEFAULT_FETCH_TIMEOUT = 30.0 # seconds
23
+
24
+ # Blocked metadata and link-local networks to mitigate cloud metadata SSRF
25
+ BLOCKED_IP_NETWORKS = (
26
+ ipaddress.ip_network("169.254.0.0/16"), # IPv4 Link-local / Cloud metadata (AWS, GCP, Azure, DigitalOcean)
27
+ ipaddress.ip_network("fd00:ec2::/64"), # AWS IMDSv6
28
+ ipaddress.ip_network("fe80::/10"), # IPv6 Link-local
29
+ ipaddress.ip_network("224.0.0.0/4"), # Multicast
30
+ ipaddress.ip_network("ff00::/8"), # IPv6 Multicast
31
+ )
32
+
33
+
34
+ def _validate_spec_url(url: str) -> None:
35
+ """Validate that a URL uses supported schemes and does not target cloud metadata endpoints."""
36
+ parsed = urllib.parse.urlparse(url)
37
+ scheme = parsed.scheme.lower()
38
+ if scheme not in ("http", "https"):
39
+ raise ValueError(f"Unsupported URL scheme: {scheme!r}. Only 'http' and 'https' are allowed.")
40
+
41
+ hostname = parsed.hostname
42
+ if not hostname:
43
+ raise ValueError(f"Invalid URL: missing hostname in {url!r}")
44
+
45
+ # Check if the hostname itself is an IP or resolves to blocked ranges
46
+ try:
47
+ ip = ipaddress.ip_address(hostname)
48
+ for net in BLOCKED_IP_NETWORKS:
49
+ if ip in net:
50
+ raise ValueError(f"Access to link-local/cloud metadata address {ip} is blocked for security.")
51
+ except ValueError as e:
52
+ if "blocked for security" in str(e):
53
+ raise
54
+ # Hostname is a domain name, attempt address lookup
55
+ try:
56
+ addr_info = socket.getaddrinfo(hostname, None)
57
+ for res in addr_info:
58
+ ip = ipaddress.ip_address(res[4][0])
59
+ for net in BLOCKED_IP_NETWORKS:
60
+ if ip in net:
61
+ raise ValueError(f"URL resolves to blocked link-local/cloud metadata address: {ip}")
62
+ except socket.gaierror:
63
+ # DNS resolution errors will be raised cleanly by urlopen
64
+ pass
65
+
66
+
67
+ class _RestrictedRedirectHandler(urllib.request.HTTPRedirectHandler):
68
+ """Enforces scheme and SSRF validation on redirects."""
69
+
70
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
71
+ _validate_spec_url(newurl)
72
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
73
+
18
74
 
19
75
  def fetch_spec(target: str) -> dict[str, Any]:
20
76
  """Load OpenAPI JSON/YAML from a local file or remote HTTP URL."""
21
- if target.startswith("http://") or target.startswith("https://"):
77
+ target_lower = target.lower()
78
+ if target_lower.startswith(("http://", "https://")):
79
+ _validate_spec_url(target)
80
+ opener = urllib.request.build_opener(_RestrictedRedirectHandler)
22
81
  req = urllib.request.Request(target, headers={"User-Agent": "SpecForge-CLI"})
23
- with urllib.request.urlopen(req) as response:
24
- content = response.read().decode("utf-8")
82
+ with opener.open(req, timeout=DEFAULT_FETCH_TIMEOUT) as response:
83
+ raw_bytes = response.read(MAX_SPEC_SIZE + 1)
84
+ if len(raw_bytes) > MAX_SPEC_SIZE:
85
+ raise ValueError(f"Remote specification exceeds maximum allowed size ({MAX_SPEC_SIZE // (1024 * 1024)} MB).")
86
+ content = raw_bytes.decode("utf-8")
87
+ elif "://" in target:
88
+ scheme = target.split("://", 1)[0]
89
+ raise ValueError(f"Unsupported URL scheme: {scheme!r}. Only 'http' and 'https' are allowed.")
25
90
  else:
91
+ # Treat as local filesystem path
26
92
  path = Path(target)
27
93
  if not path.exists():
28
94
  raise FileNotFoundError(f"Spec file not found at: {target}")
95
+ if path.stat().st_size > MAX_SPEC_SIZE:
96
+ raise ValueError(f"Local specification file exceeds maximum allowed size ({MAX_SPEC_SIZE // (1024 * 1024)} MB).")
29
97
  content = path.read_text(encoding="utf-8")
30
98
 
31
99
  try:
@@ -117,6 +185,9 @@ def run_test_command(args: argparse.Namespace) -> int:
117
185
  # Export to pytest if requested
118
186
  if args.export_pytest:
119
187
  out_path = Path(args.export_pytest)
188
+ if out_path.exists() and out_path.is_dir():
189
+ print(f"\n\033[1;31mError: Export path is a directory: {out_path}\033[0m")
190
+ return 1
120
191
  code = PytestExporter.export(all_test_cases, base_url=base_url)
121
192
  out_path.write_text(code, encoding="utf-8")
122
193
  print(f"\n🐍 \033[32mExported Pytest suite to: {out_path.resolve()}\033[0m")
@@ -22,6 +22,7 @@ class SpecForgeConfig:
22
22
  custom_css: Optional[str] = None
23
23
  logo_url: Optional[str] = None
24
24
  export_pytest: bool = True
25
+ enable_api_endpoints: bool = True
25
26
 
26
27
  def to_dict(self) -> dict[str, Any]:
27
28
  """Convert configuration to dictionary for injection into frontend client."""
@@ -39,4 +40,5 @@ class SpecForgeConfig:
39
40
  "headers": self.headers,
40
41
  "logo_url": self.logo_url,
41
42
  "export_pytest": self.export_pytest,
43
+ "enable_api_endpoints": self.enable_api_endpoints,
42
44
  }
@@ -1,7 +1,5 @@
1
1
  """Pytest code generator for SpecForge test plans."""
2
2
 
3
- import json
4
-
5
3
  from specforge.testing.invariants import TestCase
6
4
 
7
5
 
@@ -19,19 +17,18 @@ class PytestExporter:
19
17
  cases_repr: list[str] = []
20
18
 
21
19
  for tc in test_cases:
22
- payload_json = json.dumps(tc.payload) if tc.payload is not None else "None"
23
- query_json = json.dumps(tc.query_params) if tc.query_params else "None"
24
- desc_escaped = tc.description.replace('"', '\\"')
20
+ payload_repr = repr(tc.payload)
21
+ query_repr = repr(tc.query_params)
25
22
 
26
23
  case_str = (
27
24
  f' (\n'
28
- f' "{tc.endpoint}",\n'
29
- f' "{tc.method.upper()}",\n'
30
- f' {payload_json},\n'
31
- f' {query_json},\n'
25
+ f' {repr(tc.endpoint)},\n'
26
+ f' {repr(tc.method.upper())},\n'
27
+ f' {payload_repr},\n'
28
+ f' {query_repr},\n'
32
29
  f' {tc.expected_status},\n'
33
30
  f' {tc.is_negative_test},\n'
34
- f' "{desc_escaped}",\n'
31
+ f' {repr(tc.description)},\n'
35
32
  f' ),'
36
33
  )
37
34
  cases_repr.append(case_str)
@@ -43,7 +40,7 @@ class PytestExporter:
43
40
  import pytest
44
41
  import httpx
45
42
 
46
- BASE_URL = "{base_url}"
43
+ BASE_URL = {repr(base_url)}
47
44
 
48
45
  @pytest.mark.parametrize(
49
46
  "endpoint,method,payload,query_params,expected_status,is_negative,description",
@@ -1,5 +1,7 @@
1
1
  """Test invariants, status evaluations, and result data structures."""
2
2
 
3
+ import json
4
+ import shlex
3
5
  from dataclasses import dataclass, field
4
6
  from enum import Enum
5
7
  from typing import Any
@@ -153,18 +155,16 @@ def evaluate_test_result(
153
155
 
154
156
 
155
157
  def build_curl_command(test_case: TestCase, base_url: str = "http://localhost:8000") -> str:
156
- """Generate a clean cURL reproduction command for a test case."""
157
- import json
158
-
158
+ """Generate a clean, shell-escaped cURL reproduction command for a test case."""
159
159
  url = f"{base_url.rstrip('/')}{test_case.endpoint}"
160
- cmd_parts = [f"curl -X {test_case.method.upper()} '{url}'"]
160
+ cmd_parts = [f"curl -X {shlex.quote(test_case.method.upper())} {shlex.quote(url)}"]
161
161
 
162
162
  for h_name, h_val in test_case.headers.items():
163
- cmd_parts.append(f"-H '{h_name}: {h_val}'")
163
+ cmd_parts.append(f"-H {shlex.quote(f'{h_name}: {h_val}')}")
164
164
 
165
165
  if test_case.payload is not None:
166
166
  cmd_parts.append("-H 'Content-Type: application/json'")
167
- payload_str = json.dumps(test_case.payload).replace("'", "'\\''")
168
- cmd_parts.append(f"-d '{payload_str}'")
167
+ payload_str = json.dumps(test_case.payload)
168
+ cmd_parts.append(f"-d {shlex.quote(payload_str)}")
169
169
 
170
170
  return " \\\n ".join(cmd_parts)
@@ -12,6 +12,20 @@ def get_asset_html_path() -> Path:
12
12
  return Path(__file__).parent.parent / "assets" / "index.html"
13
13
 
14
14
 
15
+ def _html_safe_json(data: Any) -> str:
16
+ """Serialize data to JSON safe for embedding in HTML <script> tags.
17
+
18
+ Prevents </script> breakout and HTML comment injection by escaping
19
+ sensitive characters using standard Unicode JSON escape sequences.
20
+ """
21
+ return (
22
+ json.dumps(data)
23
+ .replace("<", "\\u003c")
24
+ .replace(">", "\\u003e")
25
+ .replace("&", "\\u0026")
26
+ )
27
+
28
+
15
29
  def render_html(
16
30
  config: SpecForgeConfig,
17
31
  spec_dict: Optional[dict[str, Any]] = None,
@@ -31,7 +45,7 @@ def render_html(
31
45
  if spec_dict is not None:
32
46
  config_data["embedded_spec"] = spec_dict
33
47
 
34
- config_json = json.dumps(config_data)
48
+ config_json = _html_safe_json(config_data)
35
49
  replacement = f"<script>window.__SPECFORGE_CONFIG__ = {config_json};</script>"
36
50
 
37
51
  # Replace placeholder or insert into <head>
@@ -1,101 +0,0 @@
1
- """FastAPI adapter for SpecForge interactive documentation and contract testing."""
2
-
3
- from typing import Any, Optional
4
-
5
- try:
6
- from fastapi import FastAPI, Request, Response
7
- from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
8
- except ImportError:
9
- FastAPI = None # type: ignore
10
-
11
- from specforge.config import SpecForgeConfig
12
- from specforge.parser.openapi3 import OpenAPIParser
13
- from specforge.testing.exporters.pytest_export import PytestExporter
14
- from specforge.testing.planner import TestPlanner
15
- from specforge.utils.html_injector import render_html
16
-
17
-
18
- class SpecForge:
19
- """Mounts the SpecForge interactive client and boundary test suite onto a FastAPI application."""
20
-
21
- def __init__(
22
- self,
23
- app: Any,
24
- path: str = "/docs",
25
- title: str = "SpecForge API Studio",
26
- spec_url: Optional[str] = None,
27
- theme: str = "dark",
28
- enable_tests: bool = True,
29
- disable_default_docs: bool = False,
30
- **kwargs: Any,
31
- ):
32
- if FastAPI is None:
33
- raise ImportError(
34
- "FastAPI is not installed. Install it with `pip install specforge[fastapi]`."
35
- )
36
-
37
- self.app = app
38
- self.path = path
39
- self.spec_url = spec_url or getattr(app, "openapi_url", "/openapi.json")
40
-
41
- self.config = SpecForgeConfig(
42
- title=title,
43
- path=path,
44
- spec_url=self.spec_url,
45
- theme=theme, # type: ignore
46
- enable_tests=enable_tests,
47
- **kwargs,
48
- )
49
-
50
- if disable_default_docs and hasattr(app, "docs_url"):
51
- app.docs_url = None
52
-
53
- self._register_routes()
54
-
55
- def _register_routes(self) -> None:
56
- """Register the documentation UI and test plan endpoints on the FastAPI app."""
57
-
58
- @self.app.get(self.path, response_class=HTMLResponse, include_in_schema=False)
59
- async def specforge_docs_endpoint(request: Request) -> Response:
60
- html = render_html(self.config)
61
- return HTMLResponse(content=html)
62
-
63
- @self.app.get(
64
- "/specforge/api/plan",
65
- response_class=JSONResponse,
66
- include_in_schema=False,
67
- )
68
- async def specforge_test_plan_endpoint(request: Request) -> Response:
69
- # Generate or retrieve active OpenAPI specification
70
- raw_spec: dict[str, Any] = self.app.openapi()
71
- parser = OpenAPIParser(raw_spec)
72
- spec_model = parser.parse()
73
-
74
- plans = []
75
- for op in spec_model.operations:
76
- plan = TestPlanner.plan_operation_tests(op)
77
- plans.append(plan.to_dict())
78
-
79
- return JSONResponse(content={"plans": plans})
80
-
81
- @self.app.get(
82
- "/specforge/api/export-pytest",
83
- response_class=PlainTextResponse,
84
- include_in_schema=False,
85
- )
86
- async def specforge_export_pytest_endpoint(request: Request) -> Response:
87
- raw_spec: dict[str, Any] = self.app.openapi()
88
- parser = OpenAPIParser(raw_spec)
89
- spec_model = parser.parse()
90
-
91
- all_test_cases = []
92
- for op in spec_model.operations:
93
- plan = TestPlanner.plan_operation_tests(op)
94
- all_test_cases.extend(plan.test_cases)
95
-
96
- base_url = str(request.base_url).rstrip("/")
97
- code = PytestExporter.export(all_test_cases, base_url=base_url)
98
- return PlainTextResponse(
99
- content=code,
100
- headers={"Content-Disposition": 'attachment; filename="test_api_contracts.py"'},
101
- )
File without changes
File without changes