specforge-api 0.1.0__py3-none-any.whl
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/__init__.py +23 -0
- specforge/adapters/fastapi.py +101 -0
- specforge/adapters/flask.py +78 -0
- specforge/assets/index.html +2995 -0
- specforge/cli/main.py +153 -0
- specforge/config.py +42 -0
- specforge/parser/models.py +131 -0
- specforge/parser/openapi3.py +285 -0
- specforge/parser/resolver.py +101 -0
- specforge/testing/exporters/pytest_export.py +89 -0
- specforge/testing/fuzzers/array_fuzzer.py +56 -0
- specforge/testing/fuzzers/boolean_fuzzer.py +38 -0
- specforge/testing/fuzzers/numeric_fuzzer.py +92 -0
- specforge/testing/fuzzers/object_fuzzer.py +99 -0
- specforge/testing/fuzzers/string_fuzzer.py +157 -0
- specforge/testing/invariants.py +170 -0
- specforge/testing/planner.py +155 -0
- specforge/utils/html_injector.py +43 -0
- specforge_api-0.1.0.dist-info/METADATA +260 -0
- specforge_api-0.1.0.dist-info/RECORD +23 -0
- specforge_api-0.1.0.dist-info/WHEEL +4 -0
- specforge_api-0.1.0.dist-info/entry_points.txt +2 -0
- specforge_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Test invariants, status evaluations, and result data structures."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestStatus(str, Enum):
|
|
9
|
+
PASS = "PASS" # Server cleanly rejected invalid input (400/422) or accepted valid input
|
|
10
|
+
WARN_LEAK = "WARN_LEAK" # Server returned 200 OK for an invalid/boundary payload (Schema Leak)
|
|
11
|
+
CRASH_500 = "CRASH_500" # Server threw an unhandled 500 error on client input (Critical Crash)
|
|
12
|
+
FAIL_STATUS = "FAIL_STATUS" # Unexpected status code (e.g. 404 instead of 422)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TestCase:
|
|
17
|
+
"""Represents an executable boundary or negative test case against an API endpoint."""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
endpoint: str
|
|
21
|
+
method: str
|
|
22
|
+
field_name: str
|
|
23
|
+
mutation_category: str
|
|
24
|
+
mutation_name: str
|
|
25
|
+
description: str
|
|
26
|
+
payload: Any
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
query_params: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
expected_status: int = 422
|
|
30
|
+
is_negative_test: bool = True
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
"""Convert test case to dictionary for frontend execution or serialization."""
|
|
34
|
+
return {
|
|
35
|
+
"id": self.id,
|
|
36
|
+
"endpoint": self.endpoint,
|
|
37
|
+
"method": self.method,
|
|
38
|
+
"field_name": self.field_name,
|
|
39
|
+
"mutation_category": self.mutation_category,
|
|
40
|
+
"mutation_name": self.mutation_name,
|
|
41
|
+
"description": self.description,
|
|
42
|
+
"payload": self.payload,
|
|
43
|
+
"headers": self.headers,
|
|
44
|
+
"query_params": self.query_params,
|
|
45
|
+
"expected_status": self.expected_status,
|
|
46
|
+
"is_negative_test": self.is_negative_test,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class TestResult:
|
|
52
|
+
"""Represents the execution outcome of a TestCase."""
|
|
53
|
+
|
|
54
|
+
test_case: TestCase
|
|
55
|
+
actual_status: int
|
|
56
|
+
latency_ms: float
|
|
57
|
+
response_body: str
|
|
58
|
+
status: TestStatus
|
|
59
|
+
message: str
|
|
60
|
+
curl_command: str = ""
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
"""Convert result to dictionary for frontend inspection."""
|
|
64
|
+
return {
|
|
65
|
+
"test_case": self.test_case.to_dict(),
|
|
66
|
+
"actual_status": self.actual_status,
|
|
67
|
+
"latency_ms": self.latency_ms,
|
|
68
|
+
"response_body": self.response_body,
|
|
69
|
+
"status": self.status.value,
|
|
70
|
+
"message": self.message,
|
|
71
|
+
"curl_command": self.curl_command,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def evaluate_test_result(
|
|
76
|
+
test_case: TestCase,
|
|
77
|
+
status_code: int,
|
|
78
|
+
response_body: str,
|
|
79
|
+
latency_ms: float,
|
|
80
|
+
) -> TestResult:
|
|
81
|
+
"""Evaluate HTTP execution against contract invariants."""
|
|
82
|
+
curl_cmd = build_curl_command(test_case)
|
|
83
|
+
|
|
84
|
+
# Invariant 1: An HTTP 500 on ANY input is an unhandled server crash bug
|
|
85
|
+
if status_code >= 500:
|
|
86
|
+
return TestResult(
|
|
87
|
+
test_case=test_case,
|
|
88
|
+
actual_status=status_code,
|
|
89
|
+
latency_ms=latency_ms,
|
|
90
|
+
response_body=response_body,
|
|
91
|
+
status=TestStatus.CRASH_500,
|
|
92
|
+
message=f"CRITICAL: Server crashed with {status_code} on {test_case.mutation_name}!",
|
|
93
|
+
curl_command=curl_cmd,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Invariant 2: Negative test expects a rejection (400 or 422). If 200/201 was returned, it's a schema leak
|
|
97
|
+
if test_case.is_negative_test:
|
|
98
|
+
if 200 <= status_code < 300:
|
|
99
|
+
return TestResult(
|
|
100
|
+
test_case=test_case,
|
|
101
|
+
actual_status=status_code,
|
|
102
|
+
latency_ms=latency_ms,
|
|
103
|
+
response_body=response_body,
|
|
104
|
+
status=TestStatus.WARN_LEAK,
|
|
105
|
+
message=(
|
|
106
|
+
f"SCHEMA LEAK: Server accepted invalid payload ({test_case.mutation_name}) "
|
|
107
|
+
f"with HTTP {status_code}. Expected 422 or 400."
|
|
108
|
+
),
|
|
109
|
+
curl_command=curl_cmd,
|
|
110
|
+
)
|
|
111
|
+
elif status_code in (400, 422):
|
|
112
|
+
return TestResult(
|
|
113
|
+
test_case=test_case,
|
|
114
|
+
actual_status=status_code,
|
|
115
|
+
latency_ms=latency_ms,
|
|
116
|
+
response_body=response_body,
|
|
117
|
+
status=TestStatus.PASS,
|
|
118
|
+
message=f"PASS: Server cleanly rejected {test_case.mutation_name} with HTTP {status_code}.",
|
|
119
|
+
curl_command=curl_cmd,
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
return TestResult(
|
|
123
|
+
test_case=test_case,
|
|
124
|
+
actual_status=status_code,
|
|
125
|
+
latency_ms=latency_ms,
|
|
126
|
+
response_body=response_body,
|
|
127
|
+
status=TestStatus.FAIL_STATUS,
|
|
128
|
+
message=f"UNEXPECTED: Got HTTP {status_code}, expected {test_case.expected_status}.",
|
|
129
|
+
curl_command=curl_cmd,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
# Happy-path test
|
|
133
|
+
if 200 <= status_code < 300:
|
|
134
|
+
return TestResult(
|
|
135
|
+
test_case=test_case,
|
|
136
|
+
actual_status=status_code,
|
|
137
|
+
latency_ms=latency_ms,
|
|
138
|
+
response_body=response_body,
|
|
139
|
+
status=TestStatus.PASS,
|
|
140
|
+
message=f"PASS: Endpoint succeeded with HTTP {status_code}.",
|
|
141
|
+
curl_command=curl_cmd,
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
return TestResult(
|
|
145
|
+
test_case=test_case,
|
|
146
|
+
actual_status=status_code,
|
|
147
|
+
latency_ms=latency_ms,
|
|
148
|
+
response_body=response_body,
|
|
149
|
+
status=TestStatus.FAIL_STATUS,
|
|
150
|
+
message=f"FAIL: Happy path failed with HTTP {status_code}.",
|
|
151
|
+
curl_command=curl_cmd,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
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
|
+
|
|
159
|
+
url = f"{base_url.rstrip('/')}{test_case.endpoint}"
|
|
160
|
+
cmd_parts = [f"curl -X {test_case.method.upper()} '{url}'"]
|
|
161
|
+
|
|
162
|
+
for h_name, h_val in test_case.headers.items():
|
|
163
|
+
cmd_parts.append(f"-H '{h_name}: {h_val}'")
|
|
164
|
+
|
|
165
|
+
if test_case.payload is not None:
|
|
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}'")
|
|
169
|
+
|
|
170
|
+
return " \\\n ".join(cmd_parts)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Test Plan orchestrator that assembles boundary test suites for operations."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from specforge.parser.models import Operation
|
|
9
|
+
from specforge.testing.fuzzers.array_fuzzer import ArrayFuzzer
|
|
10
|
+
from specforge.testing.fuzzers.boolean_fuzzer import BooleanFuzzer
|
|
11
|
+
from specforge.testing.fuzzers.numeric_fuzzer import NumericFuzzer
|
|
12
|
+
from specforge.testing.fuzzers.object_fuzzer import ObjectFuzzer
|
|
13
|
+
from specforge.testing.fuzzers.string_fuzzer import StringFuzzer
|
|
14
|
+
from specforge.testing.invariants import TestCase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TestPlan:
|
|
19
|
+
"""Represents the complete test suite planned for an operation or API."""
|
|
20
|
+
|
|
21
|
+
operation_key: str
|
|
22
|
+
endpoint: str
|
|
23
|
+
method: str
|
|
24
|
+
test_cases: list[TestCase] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
"""Convert test plan to dictionary."""
|
|
28
|
+
return {
|
|
29
|
+
"operation_key": self.operation_key,
|
|
30
|
+
"endpoint": self.endpoint,
|
|
31
|
+
"method": self.method,
|
|
32
|
+
"total_tests": len(self.test_cases),
|
|
33
|
+
"test_cases": [tc.to_dict() for tc in self.test_cases],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestPlanner:
|
|
38
|
+
"""Assembles combinatorial test cases by coordinating type fuzzers."""
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def plan_operation_tests(cls, operation: Operation) -> TestPlan:
|
|
42
|
+
"""Construct full boundary and negative test suite for a single operation."""
|
|
43
|
+
test_cases: list[TestCase] = []
|
|
44
|
+
op_key = operation.key
|
|
45
|
+
endpoint = operation.path
|
|
46
|
+
method = operation.method.lower()
|
|
47
|
+
|
|
48
|
+
# 1. Request Body Fuzzing
|
|
49
|
+
if operation.request_body and operation.request_body.schema:
|
|
50
|
+
body_schema = operation.request_body.schema
|
|
51
|
+
baseline = ObjectFuzzer.generate_valid_baseline(body_schema)
|
|
52
|
+
|
|
53
|
+
# Test 1.0: Baseline Happy Path
|
|
54
|
+
test_cases.append(
|
|
55
|
+
TestCase(
|
|
56
|
+
id=str(uuid.uuid4())[:8],
|
|
57
|
+
endpoint=endpoint,
|
|
58
|
+
method=method,
|
|
59
|
+
field_name="[body_root]",
|
|
60
|
+
mutation_category="Baseline",
|
|
61
|
+
mutation_name="Happy Path: Valid Schema Payload",
|
|
62
|
+
description="Verify endpoint succeeds with schema-compliant baseline data",
|
|
63
|
+
payload=baseline,
|
|
64
|
+
expected_status=200,
|
|
65
|
+
is_negative_test=False,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Test 1.1: Object-level mutations (missing keys, extra keys, primitive inversion)
|
|
70
|
+
object_mutations = ObjectFuzzer.generate_object_mutations(body_schema, baseline)
|
|
71
|
+
for mut_name, mut_desc, mut_payload, mut_cat in object_mutations:
|
|
72
|
+
test_cases.append(
|
|
73
|
+
TestCase(
|
|
74
|
+
id=str(uuid.uuid4())[:8],
|
|
75
|
+
endpoint=endpoint,
|
|
76
|
+
method=method,
|
|
77
|
+
field_name="[body_root]",
|
|
78
|
+
mutation_category=f"Object:{mut_cat}",
|
|
79
|
+
mutation_name=mut_name,
|
|
80
|
+
description=mut_desc,
|
|
81
|
+
payload=mut_payload,
|
|
82
|
+
expected_status=422,
|
|
83
|
+
is_negative_test=True,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Test 1.2: Field-level property mutations
|
|
88
|
+
for prop_name, prop_schema in body_schema.properties.items():
|
|
89
|
+
field_mutations = []
|
|
90
|
+
schema_type = prop_schema.type
|
|
91
|
+
|
|
92
|
+
if schema_type == "string":
|
|
93
|
+
field_mutations = StringFuzzer.generate_mutations(prop_name, prop_schema)
|
|
94
|
+
elif schema_type in ("integer", "number"):
|
|
95
|
+
field_mutations = NumericFuzzer.generate_mutations(prop_name, prop_schema)
|
|
96
|
+
elif schema_type == "boolean":
|
|
97
|
+
field_mutations = BooleanFuzzer.generate_mutations(prop_name, prop_schema)
|
|
98
|
+
elif schema_type == "array":
|
|
99
|
+
field_mutations = ArrayFuzzer.generate_mutations(prop_name, prop_schema)
|
|
100
|
+
|
|
101
|
+
# Inject each mutated field value into a fresh copy of the baseline payload
|
|
102
|
+
for mut_name, mut_desc, mut_val, mut_cat in field_mutations:
|
|
103
|
+
mutated_payload = copy.deepcopy(baseline)
|
|
104
|
+
mutated_payload[prop_name] = mut_val
|
|
105
|
+
|
|
106
|
+
test_cases.append(
|
|
107
|
+
TestCase(
|
|
108
|
+
id=str(uuid.uuid4())[:8],
|
|
109
|
+
endpoint=endpoint,
|
|
110
|
+
method=method,
|
|
111
|
+
field_name=prop_name,
|
|
112
|
+
mutation_category=f"{str(schema_type or 'field').capitalize()}:{mut_cat}",
|
|
113
|
+
mutation_name=mut_name,
|
|
114
|
+
description=mut_desc,
|
|
115
|
+
payload=mutated_payload,
|
|
116
|
+
expected_status=422,
|
|
117
|
+
is_negative_test=True,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# 2. Query Parameter Fuzzing
|
|
122
|
+
for param in operation.parameters:
|
|
123
|
+
if param.location.value == "query" and param.schema:
|
|
124
|
+
param_name = param.name
|
|
125
|
+
param_type = param.schema.type
|
|
126
|
+
|
|
127
|
+
param_mutations = []
|
|
128
|
+
if param_type == "string":
|
|
129
|
+
param_mutations = StringFuzzer.generate_mutations(param_name, param.schema)
|
|
130
|
+
elif param_type in ("integer", "number"):
|
|
131
|
+
param_mutations = NumericFuzzer.generate_mutations(param_name, param.schema)
|
|
132
|
+
|
|
133
|
+
for mut_name, mut_desc, mut_val, mut_cat in param_mutations:
|
|
134
|
+
test_cases.append(
|
|
135
|
+
TestCase(
|
|
136
|
+
id=str(uuid.uuid4())[:8],
|
|
137
|
+
endpoint=endpoint,
|
|
138
|
+
method=method,
|
|
139
|
+
field_name=param_name,
|
|
140
|
+
mutation_category=f"QueryParam:{mut_cat}",
|
|
141
|
+
mutation_name=mut_name,
|
|
142
|
+
description=mut_desc,
|
|
143
|
+
payload=None,
|
|
144
|
+
query_params={param_name: mut_val},
|
|
145
|
+
expected_status=422,
|
|
146
|
+
is_negative_test=True,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return TestPlan(
|
|
151
|
+
operation_key=op_key,
|
|
152
|
+
endpoint=endpoint,
|
|
153
|
+
method=method,
|
|
154
|
+
test_cases=test_cases,
|
|
155
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Injects configuration and specifications into the pre-compiled HTML asset."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from specforge.config import SpecForgeConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_asset_html_path() -> Path:
|
|
11
|
+
"""Return the filesystem path to the bundled index.html asset."""
|
|
12
|
+
return Path(__file__).parent.parent / "assets" / "index.html"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_html(
|
|
16
|
+
config: SpecForgeConfig,
|
|
17
|
+
spec_dict: Optional[dict[str, Any]] = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Read the inlined HTML template and inject the runtime configuration and spec."""
|
|
20
|
+
asset_path = get_asset_html_path()
|
|
21
|
+
|
|
22
|
+
if not asset_path.exists():
|
|
23
|
+
raise FileNotFoundError(
|
|
24
|
+
f"SpecForge frontend asset not found at {asset_path}. "
|
|
25
|
+
"Ensure the package was installed with assets bundled."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
html_template = asset_path.read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
config_data = config.to_dict()
|
|
31
|
+
if spec_dict is not None:
|
|
32
|
+
config_data["embedded_spec"] = spec_dict
|
|
33
|
+
|
|
34
|
+
config_json = json.dumps(config_data)
|
|
35
|
+
replacement = f"<script>window.__SPECFORGE_CONFIG__ = {config_json};</script>"
|
|
36
|
+
|
|
37
|
+
# Replace placeholder or insert into <head>
|
|
38
|
+
if "<!-- __SPECFORGE_CONFIG_INJECTION__ -->" in html_template:
|
|
39
|
+
return html_template.replace("<!-- __SPECFORGE_CONFIG_INJECTION__ -->", replacement)
|
|
40
|
+
elif "</head>" in html_template:
|
|
41
|
+
return html_template.replace("</head>", f"{replacement}\n</head>")
|
|
42
|
+
else:
|
|
43
|
+
return f"{replacement}\n{html_template}"
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: specforge-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The interactive API client and automated contract testing engine for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/specforge-api/specforge
|
|
6
|
+
Project-URL: Documentation, https://github.com/specforge-api/specforge#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/specforge-api/specforge
|
|
8
|
+
Project-URL: Issues, https://github.com/specforge-api/specforge/issues
|
|
9
|
+
Author: SpecForge Team
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 SpecForge Contributors
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: api-client,contract-testing,fastapi,fuzzing,openapi,pytest,swagger,testing
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
42
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
43
|
+
Classifier: Topic :: Software Development :: Testing
|
|
44
|
+
Requires-Python: >=3.9
|
|
45
|
+
Requires-Dist: httpx>=0.24.0
|
|
46
|
+
Requires-Dist: pydantic>=2.0.0
|
|
47
|
+
Requires-Dist: pyyaml>=6.0
|
|
48
|
+
Provides-Extra: all
|
|
49
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'all'
|
|
50
|
+
Requires-Dist: flask>=2.0.0; extra == 'all'
|
|
51
|
+
Requires-Dist: rich>=13.0.0; extra == 'all'
|
|
52
|
+
Requires-Dist: typer>=0.9.0; extra == 'all'
|
|
53
|
+
Provides-Extra: cli
|
|
54
|
+
Requires-Dist: rich>=13.0.0; extra == 'cli'
|
|
55
|
+
Requires-Dist: typer>=0.9.0; extra == 'cli'
|
|
56
|
+
Provides-Extra: dev
|
|
57
|
+
Requires-Dist: mypy>=1.5.0; extra == 'dev'
|
|
58
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
59
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
60
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
61
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
62
|
+
Provides-Extra: fastapi
|
|
63
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
|
|
64
|
+
Provides-Extra: flask
|
|
65
|
+
Requires-Dist: flask>=2.0.0; extra == 'flask'
|
|
66
|
+
Description-Content-Type: text/markdown
|
|
67
|
+
|
|
68
|
+
# SpecForge ⚡
|
|
69
|
+
|
|
70
|
+
[](https://pypi.org/project/specforge-api/)
|
|
71
|
+
[](https://pypi.org/project/specforge-api/)
|
|
72
|
+
[](https://opensource.org/licenses/MIT)
|
|
73
|
+
|
|
74
|
+
**The Interactive API Client & Automated Contract Testing Engine for Python.**
|
|
75
|
+
|
|
76
|
+
A modern, drop-in Python library that replaces Swagger UI with a beautiful, responsive API workspace **and** automatically discovers boundary vulnerabilities, missing whitespace stripping, and unhandled 500 server crashes directly against your endpoints.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 🚀 Why SpecForge?
|
|
81
|
+
|
|
82
|
+
1. **Active Quality Gate, Not Just Passive Docs**: Traditional tools like Swagger UI only show the "happy path". SpecForge algorithmically analyzes your schema constraints and runs targeted boundary fuzzing right in your browser.
|
|
83
|
+
2. **"Zero 500s" Invariant**: Verifies that edge-case inputs (e.g. `" "`, empty strings, off-by-one numbers, malformed emails) return clean client errors (`422/400`), never unhandled server exceptions (`500`).
|
|
84
|
+
3. **One-Click Pytest Exporter**: Discovered edge cases can be instantly exported into commit-ready `test_api_contracts.py` files with `@pytest.mark.parametrize`.
|
|
85
|
+
4. **Zero Frontend Build Dependencies**: The interactive workspace is fully compiled and inlined into the Python package. No Node.js or npm needed on your machine.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 📦 Installation
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install specforge-api
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
With framework extras:
|
|
96
|
+
```bash
|
|
97
|
+
pip install "specforge-api[fastapi]" # FastAPI
|
|
98
|
+
pip install "specforge-api[flask]" # Flask
|
|
99
|
+
pip install "specforge-api[all]" # Full CLI & all framework adapters
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## ⚡ Quickstart
|
|
105
|
+
|
|
106
|
+
### FastAPI
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from fastapi import FastAPI
|
|
110
|
+
from specforge import SpecForge
|
|
111
|
+
|
|
112
|
+
app = FastAPI(title="My API", version="1.0.0")
|
|
113
|
+
|
|
114
|
+
# Mount SpecForge (auto-detects /openapi.json)
|
|
115
|
+
SpecForge(app)
|
|
116
|
+
|
|
117
|
+
@app.post("/users")
|
|
118
|
+
def create_user(username: str, age: int):
|
|
119
|
+
return {"status": "created", "username": username}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Now open `http://localhost:8000/docs` in your browser. You get:
|
|
123
|
+
- A modern, interactive dark/light mode API client (Try-It-Out, request history, multi-language code snippets).
|
|
124
|
+
- An integrated **Boundary & Contract Testing Tab** to run automated fuzzing on all endpoints with one click.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### Flask
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from flask import Flask
|
|
132
|
+
from specforge.flask import SpecForge
|
|
133
|
+
|
|
134
|
+
app = Flask(__name__)
|
|
135
|
+
|
|
136
|
+
# Register Blueprint
|
|
137
|
+
SpecForge(app, spec_url="/api/spec.json", path="/docs")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 🧪 Schema-Aware Boundary Fuzzing
|
|
143
|
+
|
|
144
|
+
SpecForge generates targeted test cases according to field types:
|
|
145
|
+
|
|
146
|
+
| Field Type | Automated Test Coverage |
|
|
147
|
+
|---|---|
|
|
148
|
+
| **String** | Rejecting whitespace-only (`" "`), empty strings (`""`), min/max bounds, Unicode 4-byte emojis, null bytes (`\x00`), format violations (email, uuid, datetime), and injection probes. |
|
|
149
|
+
| **Number / Integer** | Boundary checks (`min - 1`, `max + 1`), negative values on unsigned fields, zero checks, float-into-integer pollution, and integer overflow. |
|
|
150
|
+
| **Boolean** | Strict type testing vs loose coercion (`"true"`, `"yes"`, `1`, `0`, `null`). |
|
|
151
|
+
| **Array** | Empty lists (`[]`), `minItems` / `maxItems` boundaries, and `uniqueItems: true` duplicates. |
|
|
152
|
+
| **Object** | Combinatorial required field omission, extra unexpected keys (`additionalProperties: false`), and empty bodies. |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 📁 Integrating Existing Project Tests
|
|
157
|
+
|
|
158
|
+
If your repository already contains test suites (written in `pytest` or `unittest`), SpecForge can automatically index and display them in a dedicated **Project Tests** tab and link them directly to their corresponding API routes.
|
|
159
|
+
|
|
160
|
+
### Quick Setup
|
|
161
|
+
|
|
162
|
+
Pass `test_paths` when mounting SpecForge in your application:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from fastapi import FastAPI
|
|
166
|
+
from specforge import SpecForge
|
|
167
|
+
|
|
168
|
+
app = FastAPI()
|
|
169
|
+
|
|
170
|
+
# Mount SpecForge with existing project tests
|
|
171
|
+
SpecForge(
|
|
172
|
+
app,
|
|
173
|
+
test_paths=["tests"], # Can be directories or individual test files
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Now open `/docs` to see:
|
|
178
|
+
- **📁 Project Tests Tab**: Browse all repository tests, file paths, line numbers, docstrings, and parameter matrices.
|
|
179
|
+
- **Route Coverage Linking**: View your existing tests directly inside the API Explorer alongside SpecForge's automated boundary fuzzers.
|
|
180
|
+
|
|
181
|
+
### Linking Tests to API Routes
|
|
182
|
+
|
|
183
|
+
SpecForge associates existing tests with OpenAPI routes using two mechanisms:
|
|
184
|
+
|
|
185
|
+
#### 1. Automatic HTTP Client Detection (Zero Configuration)
|
|
186
|
+
If your test uses `TestClient`, `httpx`, or `requests` to call an API path, SpecForge automatically recognizes the route:
|
|
187
|
+
```python
|
|
188
|
+
def test_user_creation_underage(client):
|
|
189
|
+
"""Rejects underage registrations."""
|
|
190
|
+
response = client.post("/api/v1/users", json={"username": "bob", "age": 16})
|
|
191
|
+
assert response.status_code == 422
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### 2. Explicit Pytest Marker (Recommended for Complex Tests)
|
|
195
|
+
For tests with dynamic URLs or mocks, declare the endpoint explicitly:
|
|
196
|
+
```python
|
|
197
|
+
import pytest
|
|
198
|
+
|
|
199
|
+
@pytest.mark.endpoint("POST", "/api/v1/users")
|
|
200
|
+
def test_user_signup_pipeline():
|
|
201
|
+
"""End-to-end user registration and event dispatch."""
|
|
202
|
+
...
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 🔒 Security & Zero-Execution Guarantee
|
|
206
|
+
|
|
207
|
+
SpecForge uses **Python Static Abstract Syntax Tree (AST) parsing** to discover tests:
|
|
208
|
+
- **Zero Code Execution**: SpecForge **never** imports or executes your test files. Tests are analyzed purely as static syntax trees.
|
|
209
|
+
- **No Side Effects**: Discovering tests will never trigger database operations, background tasks, or network calls.
|
|
210
|
+
- **Path Sandboxing**: Scanned paths are strictly verified to remain within the project root directory, preventing any path traversal.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 🛠️ CLI & CI/CD Pipeline
|
|
215
|
+
|
|
216
|
+
Run headless contract fuzzing in your continuous integration pipeline:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
specforge test http://localhost:8000/openapi.json --fail-on-500
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 💻 Local Development & Testing Guide
|
|
225
|
+
|
|
226
|
+
### 1. Run the Interactive Live Demo (Zero Dependencies)
|
|
227
|
+
You can launch and explore SpecForge immediately using Python's standard library:
|
|
228
|
+
```bash
|
|
229
|
+
python examples/standalone_demo.py
|
|
230
|
+
```
|
|
231
|
+
Open **`http://localhost:8000/docs`** in your browser to:
|
|
232
|
+
- Test API endpoints and inspect formatted JSON responses.
|
|
233
|
+
- Click **"⚡ Run Boundary Test Suite"** to watch automated fuzzing of whitespace strings, negative numbers, and missing fields.
|
|
234
|
+
- Click **"🐍 Export to Pytest"** to view and copy auto-generated test code.
|
|
235
|
+
|
|
236
|
+
### 2. Run the Automated Unit Test Suite
|
|
237
|
+
```bash
|
|
238
|
+
pytest -v
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 3. Install in Another Project Locally (Editable Mode)
|
|
242
|
+
To use this local version inside any other Python app on your machine without publishing to PyPI:
|
|
243
|
+
```bash
|
|
244
|
+
# In your other project's directory and virtualenv:
|
|
245
|
+
pip install -e c:\Research-projects\specforge
|
|
246
|
+
```
|
|
247
|
+
*Any change you make in SpecForge source code will instantly reflect in your other application without reinstalling!*
|
|
248
|
+
|
|
249
|
+
### 4. Build a Distribution Wheel (.whl)
|
|
250
|
+
```bash
|
|
251
|
+
pip install build
|
|
252
|
+
python -m build
|
|
253
|
+
# Outputs wheel file in dist/specforge-0.1.0-py3-none-any.whl
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 📄 License
|
|
259
|
+
|
|
260
|
+
SpecForge is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
specforge/__init__.py,sha256=ASOKokzk1SCxc30JpuDLT_6oUN2ByHR-gz-E3kkbK24,636
|
|
2
|
+
specforge/config.py,sha256=Br3CP2P1sLOO3YZJTYfjYWy7HNXZC_8eGpaouS3Ke6U,1499
|
|
3
|
+
specforge/adapters/fastapi.py,sha256=X8N8sAyjMByzwintgzuPi-DX44uzh8sGFfem939zLlo,3603
|
|
4
|
+
specforge/adapters/flask.py,sha256=ZMvji15SdjwF02_Bp-uoRGQlcgeEiEj28Q2jFkG6WXE,2353
|
|
5
|
+
specforge/assets/index.html,sha256=7NVQW2LJFgMiaeHhvwTquzinUlPyD5NN3aJe_16vO9I,114194
|
|
6
|
+
specforge/cli/main.py,sha256=HMMEwJItVA2yTiTbdy8Rzx7lpzsRYvNDiCqV5kK-P7o,5933
|
|
7
|
+
specforge/parser/models.py,sha256=XJF9cYfYYVyE-jlylCvOP0AkO7Sh3IPLtGQr10aSWJU,3814
|
|
8
|
+
specforge/parser/openapi3.py,sha256=6kQnH6RF0GiitUfVTBtTfO3zEVbgCi5zN9kBd43xJO4,10735
|
|
9
|
+
specforge/parser/resolver.py,sha256=VPPQ-Oe3rf3-nAklJjw9W8p8x5qVisoUF0-Rev3ZKSI,4052
|
|
10
|
+
specforge/testing/invariants.py,sha256=XuWT3jfIfEhQW16cJfpy9AJsLeE6gTYD9u7RLWHIA8I,6182
|
|
11
|
+
specforge/testing/planner.py,sha256=LEmW6V779McAjvd1A-1uAxEjam_NdIMK4z_C9HN0e18,6563
|
|
12
|
+
specforge/testing/exporters/pytest_export.py,sha256=c5v-hJ2PSSta_rATGZ_BejPKfIHPFYLR9ucADcxxdXw,2901
|
|
13
|
+
specforge/testing/fuzzers/array_fuzzer.py,sha256=CsuochFAPHuTJ4ojEJu04FCyuqDlzUB1Mv4cUPim2e4,1850
|
|
14
|
+
specforge/testing/fuzzers/boolean_fuzzer.py,sha256=GTEiSDyaKuCCMTvui33ExIY_ze2nHeRC5WO4oYXqaPM,1119
|
|
15
|
+
specforge/testing/fuzzers/numeric_fuzzer.py,sha256=9GQXzxU4ri7kNLaTvgxSXK-k2ys7omtxnV644M2BokE,3120
|
|
16
|
+
specforge/testing/fuzzers/object_fuzzer.py,sha256=WHY2tGea58Ng8rCuOwcA2YK11HGO75HEBBwBnNHaFhc,4131
|
|
17
|
+
specforge/testing/fuzzers/string_fuzzer.py,sha256=2xNP2NpKLTrhempy8UqmGv22i5kfHUkNyt1bfUchWms,5406
|
|
18
|
+
specforge/utils/html_injector.py,sha256=xYo-AHX2J4dVYRWk3ilWxfloDf7uX3HG7sMgi0T7yf8,1497
|
|
19
|
+
specforge_api-0.1.0.dist-info/METADATA,sha256=kF1XBRgAfYFp2kYAw7L8GF3lddS7zi5dFHHxiQ2H3GE,10304
|
|
20
|
+
specforge_api-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
21
|
+
specforge_api-0.1.0.dist-info/entry_points.txt,sha256=_RzXTgdjdZrMPLWQD0tMSDiQXFnitiFMgCvN8XIpxG0,64
|
|
22
|
+
specforge_api-0.1.0.dist-info/licenses/LICENSE,sha256=O_09tG0cQIwYwDQbHFG6WptgedhxMpOUQI-A-EX8QFs,1079
|
|
23
|
+
specforge_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SpecForge Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|