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
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
|
+
)
|