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/cli/main.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command-line interface for SpecForge."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from specforge.parser.openapi3 import OpenAPIParser
14
+ from specforge.testing.exporters.pytest_export import PytestExporter
15
+ from specforge.testing.invariants import TestStatus, evaluate_test_result
16
+ from specforge.testing.planner import TestPlanner
17
+
18
+
19
+ def fetch_spec(target: str) -> dict[str, Any]:
20
+ """Load OpenAPI JSON/YAML from a local file or remote HTTP URL."""
21
+ if target.startswith("http://") or target.startswith("https://"):
22
+ 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")
25
+ else:
26
+ path = Path(target)
27
+ if not path.exists():
28
+ raise FileNotFoundError(f"Spec file not found at: {target}")
29
+ content = path.read_text(encoding="utf-8")
30
+
31
+ try:
32
+ return json.loads(content)
33
+ except json.JSONDecodeError:
34
+ import yaml # type: ignore
35
+ return yaml.safe_load(content)
36
+
37
+
38
+ def run_test_command(args: argparse.Namespace) -> int:
39
+ """Execute automated contract fuzzing against an API from the terminal."""
40
+ print("\n⚡ \033[1;36mSpecForge Contract & Boundary Testing Engine\033[0m")
41
+ print(f"Loading specification from: \033[33m{args.spec}\033[0m")
42
+
43
+ try:
44
+ raw_spec = fetch_spec(args.spec)
45
+ except Exception as e:
46
+ print(f"\033[1;31mError loading spec: {e}\033[0m")
47
+ return 1
48
+
49
+ parser = OpenAPIParser(raw_spec)
50
+ spec_model = parser.parse()
51
+ print(f"Parsed \033[32m{len(spec_model.operations)}\033[0m operations across API: {spec_model.title}\n")
52
+
53
+ all_test_cases = []
54
+ for op in spec_model.operations:
55
+ plan = TestPlanner.plan_operation_tests(op)
56
+ all_test_cases.extend(plan.test_cases)
57
+
58
+ print(f"Synthesized \033[1;35m{len(all_test_cases)}\033[0m boundary test cases.\n")
59
+ print("=" * 80)
60
+
61
+ base_url = args.base_url or (spec_model.servers[0] if spec_model.servers else "http://localhost:8000")
62
+ base_url = base_url.rstrip("/")
63
+
64
+ pass_count = 0
65
+ leak_count = 0
66
+ crash_count = 0
67
+ fail_count = 0
68
+
69
+ with httpx.Client(base_url=base_url, timeout=args.timeout) as client:
70
+ for _idx, tc in enumerate(all_test_cases, 1):
71
+ start = time.perf_counter()
72
+ kwargs: dict[str, Any] = {}
73
+ if tc.payload is not None:
74
+ kwargs["json"] = tc.payload
75
+ if tc.query_params:
76
+ kwargs["params"] = tc.query_params
77
+
78
+ try:
79
+ res = client.request(tc.method.upper(), tc.endpoint, **kwargs)
80
+ actual_status = res.status_code
81
+ resp_text = res.text
82
+ except Exception as e:
83
+ actual_status = 599
84
+ resp_text = str(e)
85
+
86
+ latency = (time.perf_counter() - start) * 1000
87
+ result = evaluate_test_result(tc, actual_status, resp_text, latency)
88
+
89
+ if result.status == TestStatus.PASS:
90
+ pass_count += 1
91
+ status_icon = "\033[32m[PASS]\033[0m"
92
+ elif result.status == TestStatus.CRASH_500:
93
+ crash_count += 1
94
+ status_icon = "\033[1;31m[CRASH 500]\033[0m"
95
+ elif result.status == TestStatus.WARN_LEAK:
96
+ leak_count += 1
97
+ status_icon = "\033[33m[LEAK 200]\033[0m"
98
+ else:
99
+ fail_count += 1
100
+ status_icon = f"\033[31m[{actual_status}]\033[0m"
101
+
102
+ print(
103
+ f"{status_icon} {tc.method.upper():<6} {tc.endpoint:<28} "
104
+ f"Field: {tc.field_name:<16} ({latency:4.0f}ms)"
105
+ )
106
+ if result.status == TestStatus.CRASH_500:
107
+ print(f" \033[31m↳ {result.message}\033[0m")
108
+ print(f" \033[90mReproduction:\033[0m {result.curl_command}")
109
+
110
+ print("=" * 80)
111
+ print("\n📊 \033[1mFinal Test Summary:\033[0m")
112
+ print(f" Total Executed: {len(all_test_cases)}")
113
+ print(f" \033[32mPassed (Clean 400/422): {pass_count}\033[0m")
114
+ print(f" \033[33mSchema Leaks (Accepted): {leak_count}\033[0m")
115
+ print(f" \033[1;31mServer Crashes (500s): {crash_count}\033[0m")
116
+
117
+ # Export to pytest if requested
118
+ if args.export_pytest:
119
+ out_path = Path(args.export_pytest)
120
+ code = PytestExporter.export(all_test_cases, base_url=base_url)
121
+ out_path.write_text(code, encoding="utf-8")
122
+ print(f"\n🐍 \033[32mExported Pytest suite to: {out_path.resolve()}\033[0m")
123
+
124
+ if args.fail_on_500 and crash_count > 0:
125
+ print(f"\n\033[1;31mFAILED: {crash_count} unhandled HTTP 500 server crashes detected!\033[0m")
126
+ return 1
127
+
128
+ return 0
129
+
130
+
131
+ def cli_entrypoint() -> None:
132
+ """Main CLI entrypoint."""
133
+ parser = argparse.ArgumentParser(
134
+ prog="specforge",
135
+ description="SpecForge: Interactive API Client & Automated Contract Fuzzer",
136
+ )
137
+ subparsers = parser.add_subparsers(dest="command", required=True)
138
+
139
+ # `specforge test` command
140
+ test_parser = subparsers.add_parser("test", help="Run automated boundary tests against an API")
141
+ test_parser.add_argument("spec", help="Path or URL to OpenAPI JSON/YAML spec")
142
+ test_parser.add_argument("--base-url", help="Target API base URL (e.g. http://localhost:8000)")
143
+ test_parser.add_argument("--timeout", type=float, default=10.0, help="HTTP request timeout in seconds")
144
+ test_parser.add_argument("--fail-on-500", action="store_true", default=True, help="Exit with code 1 on HTTP 500 crashes")
145
+ test_parser.add_argument("--export-pytest", help="File path to save generated pytest test suite")
146
+
147
+ args = parser.parse_args()
148
+ if args.command == "test":
149
+ sys.exit(run_test_command(args))
150
+
151
+
152
+ if __name__ == "__main__":
153
+ cli_entrypoint()
specforge/config.py ADDED
@@ -0,0 +1,42 @@
1
+ """Configuration models for SpecForge."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Literal, Optional
5
+
6
+
7
+ @dataclass
8
+ class SpecForgeConfig:
9
+ """Master configuration for SpecForge documentation and testing studio."""
10
+
11
+ title: str = "SpecForge API Studio"
12
+ path: str = "/docs"
13
+ spec_url: Optional[str] = None
14
+ theme: Literal["dark", "light", "auto"] = "dark"
15
+ enable_tests: bool = True
16
+ allow_mutation_fuzzing: bool = True
17
+ fail_on_500: bool = True
18
+ fail_on_leak: bool = True
19
+ max_string_fuzz_length: int = 1000
20
+ timeout_seconds: float = 10.0
21
+ headers: dict[str, str] = field(default_factory=dict)
22
+ custom_css: Optional[str] = None
23
+ logo_url: Optional[str] = None
24
+ export_pytest: bool = True
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ """Convert configuration to dictionary for injection into frontend client."""
28
+ return {
29
+ "title": self.title,
30
+ "path": self.path,
31
+ "spec_url": self.spec_url,
32
+ "theme": self.theme,
33
+ "enable_tests": self.enable_tests,
34
+ "allow_mutation_fuzzing": self.allow_mutation_fuzzing,
35
+ "fail_on_500": self.fail_on_500,
36
+ "fail_on_leak": self.fail_on_leak,
37
+ "max_string_fuzz_length": self.max_string_fuzz_length,
38
+ "timeout_seconds": self.timeout_seconds,
39
+ "headers": self.headers,
40
+ "logo_url": self.logo_url,
41
+ "export_pytest": self.export_pytest,
42
+ }
@@ -0,0 +1,131 @@
1
+ """Normalized internal models for OpenAPI specs and schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class ParameterLocation(str, Enum):
11
+ QUERY = "query"
12
+ PATH = "path"
13
+ HEADER = "header"
14
+ COOKIE = "cookie"
15
+
16
+
17
+ @dataclass
18
+ class JSONSchema:
19
+ """Represents a normalized JSON Schema / OpenAPI Schema object."""
20
+
21
+ type: str | list[str] | None = None
22
+ format: str | None = None
23
+ title: str | None = None
24
+ description: str | None = None
25
+ default: Any | None = None
26
+ example: Any | None = None
27
+ enum: list[Any] | None = None
28
+
29
+ # String constraints
30
+ min_length: int | None = None
31
+ max_length: int | None = None
32
+ pattern: str | None = None
33
+
34
+ # Numeric constraints
35
+ minimum: int | float | None = None
36
+ maximum: int | float | None = None
37
+ exclusive_minimum: int | float | bool | None = None
38
+ exclusive_maximum: int | float | bool | None = None
39
+ multiple_of: int | float | None = None
40
+
41
+ # Array constraints
42
+ items: JSONSchema | None = None
43
+ min_items: int | None = None
44
+ max_items: int | None = None
45
+ unique_items: bool | None = None
46
+
47
+ # Object constraints
48
+ properties: dict[str, JSONSchema] = field(default_factory=dict)
49
+ required: list[str] = field(default_factory=list)
50
+ additional_properties: bool | JSONSchema = True
51
+
52
+ # Polymorphism / Nullability
53
+ nullable: bool = False
54
+ all_of: list[JSONSchema] = field(default_factory=list)
55
+ any_of: list[JSONSchema] = field(default_factory=list)
56
+ one_of: list[JSONSchema] = field(default_factory=list)
57
+
58
+ raw_schema: dict[str, Any] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass
62
+ class Parameter:
63
+ """Represents an API operation parameter (query, path, header, or cookie)."""
64
+
65
+ name: str
66
+ location: ParameterLocation
67
+ required: bool = False
68
+ description: str | None = None
69
+ schema: JSONSchema | None = None
70
+ example: Any | None = None
71
+
72
+
73
+ @dataclass
74
+ class RequestBody:
75
+ """Represents an operation's HTTP request body definition."""
76
+
77
+ description: str | None = None
78
+ required: bool = False
79
+ content_type: str = "application/json"
80
+ schema: JSONSchema | None = None
81
+ example: Any | None = None
82
+
83
+
84
+ @dataclass
85
+ class ResponseDefinition:
86
+ """Represents an expected HTTP response definition."""
87
+
88
+ status_code: str
89
+ description: str = ""
90
+ content_type: str = "application/json"
91
+ schema: JSONSchema | None = None
92
+
93
+
94
+ @dataclass
95
+ class Operation:
96
+ """Represents an HTTP endpoint operation (e.g. POST /users)."""
97
+
98
+ method: str # "get", "post", "put", "delete", "patch", etc.
99
+ path: str
100
+ operation_id: str | None = None
101
+ summary: str | None = None
102
+ description: str | None = None
103
+ tags: list[str] = field(default_factory=list)
104
+ parameters: list[Parameter] = field(default_factory=list)
105
+ request_body: RequestBody | None = None
106
+ responses: dict[str, ResponseDefinition] = field(default_factory=dict)
107
+
108
+ @property
109
+ def key(self) -> str:
110
+ """Unique identifier key for this operation."""
111
+ return f"{self.method.upper()} {self.path}"
112
+
113
+
114
+ @dataclass
115
+ class OpenAPISpec:
116
+ """Normalized container for an entire API specification."""
117
+
118
+ title: str = "API Documentation"
119
+ version: str = "1.0.0"
120
+ description: str = ""
121
+ servers: list[str] = field(default_factory=list)
122
+ operations: list[Operation] = field(default_factory=list)
123
+ raw_spec: dict[str, Any] = field(default_factory=dict)
124
+
125
+ def get_operation(self, method: str, path: str) -> Operation | None:
126
+ """Find an operation by method and path."""
127
+ target_method = method.lower()
128
+ for op in self.operations:
129
+ if op.method.lower() == target_method and op.path == path:
130
+ return op
131
+ return None
@@ -0,0 +1,285 @@
1
+ """OpenAPI 3.x and Swagger 2.0 parser that outputs normalized OpenAPISpec models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from specforge.parser.models import (
8
+ JSONSchema,
9
+ OpenAPISpec,
10
+ Operation,
11
+ Parameter,
12
+ ParameterLocation,
13
+ RequestBody,
14
+ ResponseDefinition,
15
+ )
16
+ from specforge.parser.resolver import RefResolver
17
+
18
+
19
+ class OpenAPIParser:
20
+ """Parses raw OpenAPI dictionaries into structured, dereferenced OpenAPISpec objects."""
21
+
22
+ def __init__(self, raw_spec: dict[str, Any]):
23
+ self.raw_spec = raw_spec
24
+ self.resolver = RefResolver(raw_spec)
25
+
26
+ def parse(self) -> OpenAPISpec:
27
+ """Execute full parsing of the OpenAPI specification."""
28
+ info = self.raw_spec.get("info", {})
29
+ title = info.get("title", "API Documentation")
30
+ version = info.get("version", "1.0.0")
31
+ description = info.get("description", "")
32
+
33
+ servers: list[str] = []
34
+ if "servers" in self.raw_spec and isinstance(self.raw_spec["servers"], list):
35
+ for s in self.raw_spec["servers"]:
36
+ if isinstance(s, dict) and "url" in s:
37
+ servers.append(s["url"])
38
+ elif "host" in self.raw_spec:
39
+ # Swagger 2.0 fallback
40
+ schemes = self.raw_spec.get("schemes", ["http"])
41
+ host = self.raw_spec["host"]
42
+ base_path = self.raw_spec.get("basePath", "")
43
+ servers.append(f"{schemes[0]}://{host}{base_path}")
44
+
45
+ operations: list[Operation] = []
46
+ paths = self.raw_spec.get("paths", {})
47
+
48
+ for path, path_item in paths.items():
49
+ if not isinstance(path_item, dict):
50
+ continue
51
+
52
+ # Path-level parameters
53
+ path_params = path_item.get("parameters", [])
54
+
55
+ for method, op_data in path_item.items():
56
+ if method.lower() not in (
57
+ "get", "post", "put", "delete", "patch", "options", "head", "trace"
58
+ ):
59
+ continue
60
+ if not isinstance(op_data, dict):
61
+ continue
62
+
63
+ operation = self._parse_operation(path, method.lower(), op_data, path_params)
64
+ operations.append(operation)
65
+
66
+ return OpenAPISpec(
67
+ title=title,
68
+ version=version,
69
+ description=description,
70
+ servers=servers,
71
+ operations=operations,
72
+ raw_spec=self.raw_spec,
73
+ )
74
+
75
+ def _parse_operation(
76
+ self,
77
+ path: str,
78
+ method: str,
79
+ op_data: dict[str, Any],
80
+ path_level_params: list[Any],
81
+ ) -> Operation:
82
+ """Parse a single operation item."""
83
+ summary = op_data.get("summary")
84
+ description = op_data.get("description")
85
+ operation_id = op_data.get("operationId")
86
+ tags = op_data.get("tags", [])
87
+
88
+ # Merge path-level and operation-level parameters
89
+ merged_params_raw = list(path_level_params) + op_data.get("parameters", [])
90
+ parameters: list[Parameter] = []
91
+
92
+ for p_raw in merged_params_raw:
93
+ derefed_param = self.resolver.dereference(p_raw)
94
+ if not isinstance(derefed_param, dict) or "name" not in derefed_param:
95
+ continue
96
+
97
+ param_loc_str = derefed_param.get("in", "query")
98
+ try:
99
+ param_loc = ParameterLocation(param_loc_str)
100
+ except ValueError:
101
+ param_loc = ParameterLocation.QUERY
102
+
103
+ schema_raw = derefed_param.get("schema", {})
104
+ param_schema = self._parse_schema(schema_raw) if schema_raw else None
105
+
106
+ parameters.append(
107
+ Parameter(
108
+ name=derefed_param["name"],
109
+ location=param_loc,
110
+ required=derefed_param.get("required", False),
111
+ description=derefed_param.get("description"),
112
+ schema=param_schema,
113
+ example=derefed_param.get("example"),
114
+ )
115
+ )
116
+
117
+ # Parse request body (OpenAPI 3.x)
118
+ request_body: RequestBody | None = None
119
+ if "requestBody" in op_data:
120
+ rb_raw = self.resolver.dereference(op_data["requestBody"])
121
+ if isinstance(rb_raw, dict):
122
+ content = rb_raw.get("content", {})
123
+ content_type = "application/json"
124
+ body_schema_raw = None
125
+
126
+ if "application/json" in content:
127
+ content_type = "application/json"
128
+ body_schema_raw = content["application/json"].get("schema")
129
+ elif content:
130
+ content_type = next(iter(content.keys()))
131
+ body_schema_raw = content[content_type].get("schema")
132
+
133
+ parsed_schema = (
134
+ self._parse_schema(body_schema_raw) if body_schema_raw else None
135
+ )
136
+ request_body = RequestBody(
137
+ description=rb_raw.get("description"),
138
+ required=rb_raw.get("required", False),
139
+ content_type=content_type,
140
+ schema=parsed_schema,
141
+ )
142
+
143
+ # Parse responses
144
+ responses: dict[str, ResponseDefinition] = {}
145
+ for status_code, resp_raw in op_data.get("responses", {}).items():
146
+ derefed_resp = self.resolver.dereference(resp_raw)
147
+ if not isinstance(derefed_resp, dict):
148
+ continue
149
+
150
+ resp_desc = derefed_resp.get("description", "")
151
+ resp_content = derefed_resp.get("content", {})
152
+ resp_schema = None
153
+ resp_content_type = "application/json"
154
+
155
+ if "application/json" in resp_content:
156
+ resp_schema_raw = resp_content["application/json"].get("schema")
157
+ if resp_schema_raw:
158
+ resp_schema = self._parse_schema(resp_schema_raw)
159
+ elif resp_content:
160
+ resp_content_type = next(iter(resp_content.keys()))
161
+ resp_schema_raw = resp_content[resp_content_type].get("schema")
162
+ if resp_schema_raw:
163
+ resp_schema = self._parse_schema(resp_schema_raw)
164
+
165
+ responses[str(status_code)] = ResponseDefinition(
166
+ status_code=str(status_code),
167
+ description=resp_desc,
168
+ content_type=resp_content_type,
169
+ schema=resp_schema,
170
+ )
171
+
172
+ return Operation(
173
+ method=method,
174
+ path=path,
175
+ operation_id=operation_id,
176
+ summary=summary,
177
+ description=description,
178
+ tags=tags,
179
+ parameters=parameters,
180
+ request_body=request_body,
181
+ responses=responses,
182
+ )
183
+
184
+ def _parse_schema(self, schema_raw: Any) -> JSONSchema:
185
+ """Convert a raw dereferenced schema dictionary into a typed JSONSchema."""
186
+ derefed = self.resolver.dereference(schema_raw)
187
+ if not isinstance(derefed, dict):
188
+ return JSONSchema(raw_schema={})
189
+
190
+ schema_type = derefed.get("type")
191
+ format_val = derefed.get("format")
192
+ title = derefed.get("title")
193
+ description = derefed.get("description")
194
+ default = derefed.get("default")
195
+ example = derefed.get("example")
196
+ enum = derefed.get("enum")
197
+
198
+ # String constraints
199
+ min_length = derefed.get("minLength")
200
+ max_length = derefed.get("maxLength")
201
+ pattern = derefed.get("pattern")
202
+
203
+ # Numeric constraints
204
+ minimum = derefed.get("minimum")
205
+ maximum = derefed.get("maximum")
206
+ exclusive_minimum = derefed.get("exclusiveMinimum")
207
+ exclusive_maximum = derefed.get("exclusiveMaximum")
208
+ multiple_of = derefed.get("multipleOf")
209
+
210
+ # Array constraints
211
+ items_schema = None
212
+ if "items" in derefed:
213
+ items_schema = self._parse_schema(derefed["items"])
214
+ min_items = derefed.get("minItems")
215
+ max_items = derefed.get("maxItems")
216
+ unique_items = derefed.get("uniqueItems")
217
+
218
+ # Object constraints
219
+ properties: dict[str, JSONSchema] = {}
220
+ for prop_name, prop_raw in derefed.get("properties", {}).items():
221
+ properties[prop_name] = self._parse_schema(prop_raw)
222
+
223
+ required = derefed.get("required", [])
224
+ if not isinstance(required, list):
225
+ required = []
226
+
227
+ additional_properties_raw = derefed.get("additionalProperties", True)
228
+ additional_properties: bool | JSONSchema
229
+ if isinstance(additional_properties_raw, dict):
230
+ additional_properties = self._parse_schema(additional_properties_raw)
231
+ else:
232
+ additional_properties = bool(additional_properties_raw)
233
+
234
+ nullable = bool(derefed.get("nullable", False))
235
+
236
+ # Handle allOf merging if present
237
+ all_of: list[JSONSchema] = []
238
+ if "allOf" in derefed and isinstance(derefed["allOf"], list):
239
+ all_of = [self._parse_schema(item) for item in derefed["allOf"]]
240
+ # Inherit properties from allOf members
241
+ for parent_schema in all_of:
242
+ for k, v in parent_schema.properties.items():
243
+ if k not in properties:
244
+ properties[k] = v
245
+ for req_item in parent_schema.required:
246
+ if req_item not in required:
247
+ required.append(req_item)
248
+
249
+ any_of: list[JSONSchema] = []
250
+ if "anyOf" in derefed and isinstance(derefed["anyOf"], list):
251
+ any_of = [self._parse_schema(item) for item in derefed["anyOf"]]
252
+
253
+ one_of: list[JSONSchema] = []
254
+ if "oneOf" in derefed and isinstance(derefed["oneOf"], list):
255
+ one_of = [self._parse_schema(item) for item in derefed["oneOf"]]
256
+
257
+ return JSONSchema(
258
+ type=schema_type,
259
+ format=format_val,
260
+ title=title,
261
+ description=description,
262
+ default=default,
263
+ example=example,
264
+ enum=enum,
265
+ min_length=min_length,
266
+ max_length=max_length,
267
+ pattern=pattern,
268
+ minimum=minimum,
269
+ maximum=maximum,
270
+ exclusive_minimum=exclusive_minimum,
271
+ exclusive_maximum=exclusive_maximum,
272
+ multiple_of=multiple_of,
273
+ items=items_schema,
274
+ min_items=min_items,
275
+ max_items=max_items,
276
+ unique_items=unique_items,
277
+ properties=properties,
278
+ required=required,
279
+ additional_properties=additional_properties,
280
+ nullable=nullable,
281
+ all_of=all_of,
282
+ any_of=any_of,
283
+ one_of=one_of,
284
+ raw_schema=derefed,
285
+ )