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.
- {specforge_api-0.1.3 → specforge_api-0.1.5}/.gitignore +8 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/PKG-INFO +3 -3
- {specforge_api-0.1.3 → specforge_api-0.1.5}/pyproject.toml +3 -3
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/__init__.py +1 -1
- specforge_api-0.1.5/src/specforge/adapters/fastapi.py +116 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/adapters/flask.py +15 -12
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/assets/index.html +91 -7
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/cli/main.py +74 -3
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/config.py +2 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/exporters/pytest_export.py +8 -11
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/invariants.py +7 -7
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/utils/html_injector.py +15 -1
- specforge_api-0.1.3/src/specforge/adapters/fastapi.py +0 -101
- {specforge_api-0.1.3 → specforge_api-0.1.5}/LICENSE +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/README.md +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/parser/models.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/parser/openapi3.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/parser/resolver.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/array_fuzzer.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/boolean_fuzzer.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/numeric_fuzzer.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/object_fuzzer.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/fuzzers/string_fuzzer.py +0 -0
- {specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/planner.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.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.
|
|
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.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.
|
|
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.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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
71
|
+
parser = OpenAPIParser(self.spec_dict)
|
|
72
|
+
spec_model = parser.parse()
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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')
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
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
|
}
|
{specforge_api-0.1.3 → specforge_api-0.1.5}/src/specforge/testing/exporters/pytest_export.py
RENAMED
|
@@ -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
|
-
|
|
23
|
-
|
|
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'
|
|
29
|
-
f'
|
|
30
|
-
f' {
|
|
31
|
-
f' {
|
|
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'
|
|
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 =
|
|
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()}
|
|
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)
|
|
168
|
-
cmd_parts.append(f"-d
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|