specforge-api 0.1.3__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.3 → specforge_api-0.1.5}/.gitignore +8 -0
  2. {specforge_api-0.1.3 → specforge_api-0.1.5}/PKG-INFO +3 -3
  3. {specforge_api-0.1.3 → specforge_api-0.1.5}/pyproject.toml +3 -3
  4. {specforge_api-0.1.3 → 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.3 → specforge_api-0.1.5}/src/specforge/adapters/flask.py +15 -12
  7. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/assets/index.html +91 -7
  8. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/cli/main.py +74 -3
  9. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/config.py +2 -0
  10. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/exporters/pytest_export.py +8 -11
  11. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/invariants.py +7 -7
  12. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/utils/html_injector.py +15 -1
  13. specforge_api-0.1.3/src/specforge/adapters/fastapi.py +0 -101
  14. {specforge_api-0.1.3 → specforge_api-0.1.5}/LICENSE +0 -0
  15. {specforge_api-0.1.3 → specforge_api-0.1.5}/README.md +0 -0
  16. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/parser/models.py +0 -0
  17. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/parser/openapi3.py +0 -0
  18. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/parser/resolver.py +0 -0
  19. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
  20. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
  21. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
  22. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
  23. {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
  24. {specforge_api-0.1.3 → 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.3
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.3"
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.3"
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)
@@ -1522,8 +1522,8 @@
1522
1522
  }
1523
1523
  parseOperations();
1524
1524
  renderEndpointList();
1525
+ await synthesizeTestPlan();
1525
1526
  selectOperation(0);
1526
- synthesizeTestPlan();
1527
1527
  } catch (err) {
1528
1528
  console.error("Failed to load OpenAPI spec:", err);
1529
1529
  }
@@ -1916,6 +1916,38 @@
1916
1916
  document.getElementById("request-body-input").value = JSON.stringify(baseline, null, 2);
1917
1917
  }
1918
1918
 
1919
+ function resolveRef(schema, visited = new Set()) {
1920
+ if (!schema || typeof schema !== 'object') return schema;
1921
+ if (schema.$ref && typeof schema.$ref === 'string') {
1922
+ if (visited.has(schema.$ref)) return schema;
1923
+ visited.add(schema.$ref);
1924
+ const refPath = schema.$ref.replace(/^#\//, '').split('/');
1925
+ let target = activeSpec;
1926
+ for (const seg of refPath) {
1927
+ if (!target) break;
1928
+ target = target[seg];
1929
+ }
1930
+ if (target) {
1931
+ const resolvedTarget = resolveRef(target, visited);
1932
+ return { ...resolvedTarget, ...schema, $ref: undefined };
1933
+ }
1934
+ }
1935
+ if (Array.isArray(schema.allOf)) {
1936
+ let merged = {};
1937
+ for (const sub of schema.allOf) {
1938
+ const resolvedSub = resolveRef(sub, visited);
1939
+ merged = {
1940
+ ...merged,
1941
+ ...resolvedSub,
1942
+ properties: { ...(merged.properties || {}), ...(resolvedSub.properties || {}) },
1943
+ required: [...(merged.required || []), ...(resolvedSub.required || [])]
1944
+ };
1945
+ }
1946
+ return { ...merged, ...schema, allOf: undefined };
1947
+ }
1948
+ return schema;
1949
+ }
1950
+
1919
1951
  function generateMockFromOp(op) {
1920
1952
  const schema = op.raw?.requestBody?.content?.['application/json']?.schema;
1921
1953
  if (!schema) return { message: "sample_payload" };
@@ -1923,7 +1955,11 @@
1923
1955
  }
1924
1956
 
1925
1957
  function generateMockFromSchema(schema) {
1926
- if (schema.example) return schema.example;
1958
+ if (!schema) return "value";
1959
+ schema = resolveRef(schema);
1960
+ if (schema.example !== undefined) return schema.example;
1961
+ if (schema.default !== undefined) return schema.default;
1962
+ if (schema.enum && schema.enum.length > 0) return schema.enum[0];
1927
1963
  if (schema.type === 'object' || schema.properties) {
1928
1964
  const obj = {};
1929
1965
  for (const [k, v] of Object.entries(schema.properties || {})) {
@@ -1933,11 +1969,19 @@
1933
1969
  }
1934
1970
  if (schema.type === 'string') {
1935
1971
  if (schema.format === 'email') return "user@example.com";
1972
+ if (schema.format === 'uuid') return "123e4567-e89b-12d3-a456-426614174000";
1973
+ if (schema.format === 'date-time') return new Date().toISOString();
1974
+ if (schema.format === 'date') return "2026-10-28";
1936
1975
  return "sample_string";
1937
1976
  }
1938
- if (schema.type === 'integer' || schema.type === 'number') return schema.minimum || 25;
1977
+ if (schema.type === 'integer' || schema.type === 'number') return schema.minimum !== undefined ? schema.minimum : 25;
1939
1978
  if (schema.type === 'boolean') return true;
1940
- if (schema.type === 'array') return [];
1979
+ if (schema.type === 'array') {
1980
+ if (schema.items) {
1981
+ return [generateMockFromSchema(schema.items)];
1982
+ }
1983
+ return [];
1984
+ }
1941
1985
  return "value";
1942
1986
  }
1943
1987
 
@@ -2013,10 +2057,47 @@
2013
2057
  }
2014
2058
 
2015
2059
  // Fuzzing & Testing Generator
2016
- function synthesizeTestPlan() {
2060
+ async function synthesizeTestPlan() {
2061
+ // 1. Try to fetch rich server-synthesized test plan if available
2062
+ try {
2063
+ const res = await fetch("/specforge/api/plan");
2064
+ if (res.ok) {
2065
+ const data = await res.json();
2066
+ if (data && data.plans && data.plans.length > 0) {
2067
+ testCases = [];
2068
+ data.plans.forEach(plan => {
2069
+ (plan.test_cases || []).forEach(tc => {
2070
+ testCases.push({
2071
+ opMethod: plan.method.toLowerCase(),
2072
+ opPath: plan.endpoint,
2073
+ endpoint: plan.endpoint,
2074
+ method: plan.method.toUpperCase(),
2075
+ field: tc.field_name || tc.target_field || "payload",
2076
+ category: tc.invariant_type || tc.category || "Boundary",
2077
+ desc: tc.description || tc.name,
2078
+ payload: tc.payload,
2079
+ headers: tc.headers || {},
2080
+ queryParams: tc.query_params || {},
2081
+ expected: tc.expected_status || 422,
2082
+ isNegative: tc.is_negative_test !== false
2083
+ });
2084
+ });
2085
+ });
2086
+ mergeCustomProbesFromStorage();
2087
+ populateTestScopeDropdown();
2088
+ renderEndpointPlannedTests();
2089
+ renderPlannedTestTable();
2090
+ return;
2091
+ }
2092
+ }
2093
+ } catch (e) {
2094
+ // Fall back to client-side synthesis
2095
+ }
2096
+
2017
2097
  testCases = [];
2018
2098
  operations.forEach(op => {
2019
- const schema = op.raw?.requestBody?.content?.['application/json']?.schema;
2099
+ const rawSchema = op.raw?.requestBody?.content?.['application/json']?.schema;
2100
+ const schema = resolveRef(rawSchema);
2020
2101
  const rawParams = op.raw?.parameters || [];
2021
2102
  const opMethod = op.method.toLowerCase();
2022
2103
  const opPath = op.path;
@@ -2025,7 +2106,8 @@
2025
2106
  if (schema && schema.properties) {
2026
2107
  const baseline = generateMockFromSchema(schema);
2027
2108
 
2028
- for (const [propName, propSchema] of Object.entries(schema.properties)) {
2109
+ for (const [propName, rawPropSchema] of Object.entries(schema.properties)) {
2110
+ const propSchema = resolveRef(rawPropSchema);
2029
2111
  if (propSchema.type === 'string') {
2030
2112
  testCases.push({
2031
2113
  opMethod,
@@ -2720,6 +2802,8 @@
2720
2802
  const tbody = document.getElementById("test-table-body");
2721
2803
  const activeCases = getActiveTestCases();
2722
2804
  window.__activeTestCases = activeCases;
2805
+ const statTotalEl = document.getElementById("stat-total");
2806
+ if (statTotalEl) statTotalEl.textContent = activeCases.length;
2723
2807
  tbody.innerHTML = "";
2724
2808
 
2725
2809
  if (activeCases.length === 0) {
@@ -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