spec2openapi 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.
spec2openapi/cli.py ADDED
@@ -0,0 +1,330 @@
1
+ """spec2openapi command line interface.
2
+
3
+ spec2openapi convert service.wsdl -o service.openapi.yaml
4
+ spec2openapi upgrade swagger2.json -o service.openapi.yaml
5
+ spec2openapi inspect service.wsdl
6
+ spec2openapi validate service.openapi.yaml # incl. FastMCP round-trip
7
+ spec2openapi serve spec.yaml --transport http # reference MCP runtime
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__
17
+
18
+ _MCP_HINT = "install the MCP runtime extras first: pip install 'spec2openapi[mcp]'"
19
+
20
+
21
+ def _is_wsdl_source(src: str) -> bool:
22
+ low = src.lower()
23
+ if low.endswith((".yaml", ".yml", ".json")):
24
+ return False
25
+ if low.endswith((".wsdl", "?wsdl")):
26
+ return True
27
+ if "?" in low and "wsdl" in low.split("?", 1)[1]: # e.g. ?singleWsdl
28
+ return True
29
+ p = Path(src)
30
+ if p.exists():
31
+ head = p.read_text(encoding="utf-8", errors="replace")[:512].lstrip()
32
+ return head.startswith("<")
33
+ return False
34
+
35
+
36
+ def _load_or_convert(source: str) -> dict:
37
+ from .convert import convert_wsdl, load_spec
38
+ from .swagger import convert_swagger, is_swagger2
39
+
40
+ if _is_wsdl_source(source):
41
+ return convert_wsdl(source)
42
+ spec = load_spec(source)
43
+ if is_swagger2(spec):
44
+ print("note: Swagger 2.0 input detected; upgrading to OpenAPI 3.0 "
45
+ "in memory (see `spec2openapi upgrade`)", file=sys.stderr)
46
+ spec = convert_swagger(spec)
47
+ return spec
48
+
49
+
50
+ def _add_bridge_args(p: argparse.ArgumentParser) -> None:
51
+ p.add_argument("--endpoint", help="override SOAP endpoint URL "
52
+ "(env SPEC2OPENAPI_ENDPOINT)")
53
+ p.add_argument("--auth", choices=["basic", "wsse"],
54
+ help="authentication scheme (env SPEC2OPENAPI_AUTH)")
55
+ p.add_argument("--username", help="env SPEC2OPENAPI_USERNAME")
56
+ p.add_argument("--password", help="env SPEC2OPENAPI_PASSWORD")
57
+ p.add_argument("--timeout", type=float, default=None,
58
+ help="SOAP call timeout seconds (default 30)")
59
+ p.add_argument("--insecure", action="store_true",
60
+ help="disable TLS certificate verification")
61
+
62
+
63
+ def _bridge_options(args):
64
+ from .bridge import BridgeOptions
65
+
66
+ opt = BridgeOptions.from_env()
67
+ if args.endpoint:
68
+ opt.endpoint = args.endpoint
69
+ if args.auth:
70
+ opt.auth = args.auth
71
+ if args.username:
72
+ opt.username = args.username
73
+ if args.password:
74
+ opt.password = args.password
75
+ if args.timeout is not None:
76
+ opt.timeout = args.timeout
77
+ if args.insecure:
78
+ opt.verify = False
79
+ return opt
80
+
81
+
82
+ def cmd_convert(args) -> int:
83
+ from .convert import convert_wsdl, dump_spec
84
+
85
+ spec = convert_wsdl(
86
+ args.wsdl, title=args.title, version=args.spec_version,
87
+ base_path=args.base_path, service=args.service, port=args.port_name,
88
+ prefer_soap12=args.prefer_soap12, strict=args.strict,
89
+ openapi_version=args.openapi_version,
90
+ forbid_external=args.forbid_external, huge_tree=args.huge_tree,
91
+ )
92
+ fmt = args.format or ("json" if (args.output or "").endswith(".json") else "yaml")
93
+ text = dump_spec(spec, fmt)
94
+ if args.output:
95
+ Path(args.output).write_text(text, encoding="utf-8")
96
+ n = len(spec.get("paths", {}))
97
+ print(f"wrote {args.output} ({n} operations)", file=sys.stderr)
98
+ else:
99
+ print(text)
100
+ return 0
101
+
102
+
103
+ def cmd_inspect(args) -> int:
104
+ from .parser import parse_wsdl
105
+
106
+ parsed = parse_wsdl(args.wsdl, forbid_external=args.forbid_external,
107
+ huge_tree=args.huge_tree)
108
+ print(f"service : {parsed.name}")
109
+ if parsed.documentation:
110
+ print(f"doc : {parsed.documentation}")
111
+ print(f"ops : {len(parsed.operations)}")
112
+ for op in parsed.operations:
113
+ params = ", ".join(n for n, _ in op.input_element.type.elements)
114
+ extras = []
115
+ if op.headers:
116
+ extras.append(f"headers: {', '.join(h.part for h in op.headers)}")
117
+ if op.faults:
118
+ extras.append(f"faults: {', '.join(f.name for f in op.faults)}")
119
+ suffix = f" ({'; '.join(extras)})" if extras else ""
120
+ print(f" - {op.op_id}({params}) [SOAP {op.soap_version}, {op.style}]"
121
+ f" {op.endpoint}{suffix}")
122
+ if op.documentation:
123
+ print(f" {op.documentation}")
124
+ for name, reason in parsed.skipped:
125
+ print(f" ! skipped {name}: {reason}")
126
+ return 0
127
+
128
+
129
+ def cmd_upgrade(args) -> int:
130
+ from .convert import dump_spec, load_spec
131
+ from .swagger import convert_swagger
132
+
133
+ spec = load_spec(args.source)
134
+ upgraded = convert_swagger(spec, openapi_version=args.openapi_version)
135
+
136
+ report = upgraded.get("x-s2o", {})
137
+ for kind in ("assumptions", "lossy"):
138
+ for msg in report.get(kind, []):
139
+ print(f"{kind[:-1] if kind.endswith('s') else kind}: {msg}",
140
+ file=sys.stderr)
141
+
142
+ fmt = args.format or ("json" if (args.output or "").endswith(".json") else "yaml")
143
+ text = dump_spec(upgraded, fmt)
144
+ if args.output:
145
+ Path(args.output).write_text(text, encoding="utf-8")
146
+ n = len(upgraded.get("paths", {}))
147
+ print(f"wrote {args.output} ({n} paths, "
148
+ f"{len(report.get('assumptions', []))} assumptions, "
149
+ f"{len(report.get('lossy', []))} lossy)", file=sys.stderr)
150
+ else:
151
+ print(text)
152
+ return 0
153
+
154
+
155
+ def cmd_validate(args) -> int:
156
+ """Static checks + optional FastMCP round-trip on a spec (or WSDL)."""
157
+ import re
158
+
159
+ spec = _load_or_convert(args.source)
160
+ problems: list[str] = []
161
+
162
+ paths = spec.get("paths", {})
163
+ if not paths:
164
+ problems.append("spec has no paths")
165
+ op_ids: list[str] = []
166
+ for path, item in paths.items():
167
+ for method, op in (item or {}).items():
168
+ if not isinstance(op, dict):
169
+ continue
170
+ oid = op.get("operationId")
171
+ if not oid:
172
+ problems.append(f"{method.upper()} {path}: missing operationId")
173
+ continue
174
+ op_ids.append(oid)
175
+ if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", oid):
176
+ problems.append(f"{oid}: not a safe MCP tool name")
177
+ if op.get("x-soap") and not op["x-soap"].get("input", {}).get("element"):
178
+ problems.append(f"{oid}: x-soap.input.element missing")
179
+ dupes = {o for o in op_ids if op_ids.count(o) > 1}
180
+ if dupes:
181
+ problems.append(f"duplicate operationIds: {sorted(dupes)}")
182
+
183
+ print(f"operations : {len(op_ids)}")
184
+ print(f"component schemas : {len(spec.get('components', {}).get('schemas', {}))}")
185
+
186
+ try: # optional deep validation
187
+ from openapi_spec_validator import validate as osv_validate
188
+
189
+ osv_validate(spec)
190
+ print("openapi-spec-validator: OK")
191
+ except ImportError:
192
+ print("openapi-spec-validator: not installed (skipped)")
193
+ except Exception as exc:
194
+ problems.append(f"openapi-spec-validator: {exc}")
195
+
196
+ try: # FastMCP round-trip: the compatibility this project guarantees
197
+ import anyio
198
+ from fastmcp import Client, FastMCP
199
+
200
+ mcp = FastMCP.from_openapi(openapi_spec=spec, name="validate")
201
+
202
+ async def _tools():
203
+ async with Client(mcp) as client:
204
+ return await client.list_tools()
205
+
206
+ tools = anyio.run(_tools)
207
+ print(f"FastMCP round-trip: OK ({len(tools)} tools)")
208
+ for tool in sorted(tools, key=lambda t: t.name):
209
+ schema = getattr(tool, "inputSchema", None) or {}
210
+ params = ", ".join((schema.get("properties") or {}).keys())
211
+ print(f" - {tool.name}({params})")
212
+ tool_names = {t.name for t in tools}
213
+
214
+ def _norm(s: str) -> str: # FastMCP tool-name normalization
215
+ return re.sub(r"[^A-Za-z0-9_]", "_", s)
216
+
217
+ renamed = {o for o in op_ids
218
+ if o not in tool_names and _norm(o) in tool_names}
219
+ for o in sorted(renamed):
220
+ print(f"note: operationId '{o}' is exposed as tool "
221
+ f"'{_norm(o)}' (FastMCP normalization)")
222
+ missing = {o for o in op_ids
223
+ if o not in tool_names and _norm(o) not in tool_names}
224
+ if missing:
225
+ problems.append(f"operations not materialized as tools: {sorted(missing)}")
226
+ except ImportError:
227
+ print(f"FastMCP round-trip: fastmcp not installed (skipped) - {_MCP_HINT}")
228
+ except Exception as exc:
229
+ problems.append(f"FastMCP round-trip failed: {exc}")
230
+
231
+ if problems:
232
+ print("\nFAIL")
233
+ for p in problems:
234
+ print(f" ! {p}")
235
+ return 1
236
+ print("\nOK: spec is FastMCP-convertible")
237
+ return 0
238
+
239
+
240
+ def cmd_serve(args) -> int:
241
+ try:
242
+ from .server import from_openapi_spec
243
+ except ImportError:
244
+ print(f"error: {_MCP_HINT}", file=sys.stderr)
245
+ return 2
246
+
247
+ spec = _load_or_convert(args.source)
248
+ mcp = from_openapi_spec(
249
+ spec, options=_bridge_options(args),
250
+ validate_output=args.validate_output,
251
+ )
252
+ if args.transport == "http":
253
+ mcp.run(transport="http", host=args.host, port=args.port,
254
+ path=args.path, show_banner=False)
255
+ else:
256
+ mcp.run(show_banner=False) # stdio
257
+ return 0
258
+
259
+
260
+ def main(argv: list[str] | None = None) -> int:
261
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr,
262
+ format="%(levelname)s %(name)s: %(message)s")
263
+ ap = argparse.ArgumentParser(
264
+ prog="spec2openapi",
265
+ description="Convert SOAP/WSDL services into FastMCP-ready OpenAPI "
266
+ "specs (x-soap extensions carry the SOAP binding).",
267
+ )
268
+ ap.add_argument("--version", action="version",
269
+ version=f"spec2openapi {__version__}")
270
+ sub = ap.add_subparsers(dest="cmd", required=True)
271
+
272
+ c = sub.add_parser("convert", help="WSDL -> OpenAPI spec with x-soap extensions")
273
+ c.add_argument("wsdl", help="WSDL path or URL")
274
+ c.add_argument("-o", "--output", help="output file (default: stdout)")
275
+ c.add_argument("--format", choices=["yaml", "json"])
276
+ c.add_argument("--title", help="override info.title")
277
+ c.add_argument("--spec-version", default="1.0.0", help="info.version")
278
+ c.add_argument("--base-path", default="/operations")
279
+ c.add_argument("--openapi-version", choices=["3.0", "3.1"], default="3.0")
280
+ c.add_argument("--service", help="pick a wsdl:service by name")
281
+ c.add_argument("--port-name", help="pick a wsdl:port by name")
282
+ c.add_argument("--prefer-soap12", action="store_true")
283
+ c.add_argument("--strict", action="store_true",
284
+ help="fail instead of skipping unsupported operations")
285
+ c.add_argument("--forbid-external", action="store_true",
286
+ help="refuse to fetch remote wsdl:/xsd: imports "
287
+ "(recommended for WSDLs from untrusted sources)")
288
+ c.add_argument("--huge-tree", action="store_true",
289
+ help="lift libxml2 depth/size limits for very large WSDLs")
290
+ c.set_defaults(fn=cmd_convert)
291
+
292
+ i = sub.add_parser("inspect", help="list operations found in a WSDL")
293
+ i.add_argument("wsdl", help="WSDL path or URL")
294
+ i.add_argument("--forbid-external", action="store_true",
295
+ help="refuse to fetch remote wsdl:/xsd: imports")
296
+ i.add_argument("--huge-tree", action="store_true",
297
+ help="lift libxml2 depth/size limits for very large WSDLs")
298
+ i.set_defaults(fn=cmd_inspect)
299
+
300
+ u = sub.add_parser("upgrade",
301
+ help="Swagger 2.0 -> OpenAPI 3.x (FastMCP needs 3.x)")
302
+ u.add_argument("source", help="Swagger 2.0 file (.yaml/.json)")
303
+ u.add_argument("-o", "--output", help="output file (default: stdout)")
304
+ u.add_argument("--format", choices=["yaml", "json"])
305
+ u.add_argument("--openapi-version", choices=["3.0", "3.1"], default="3.0")
306
+ u.set_defaults(fn=cmd_upgrade)
307
+
308
+ v = sub.add_parser("validate",
309
+ help="check a spec (or WSDL) for FastMCP convertibility")
310
+ v.add_argument("source", help="OpenAPI spec (.yaml/.json) or WSDL path/URL")
311
+ v.set_defaults(fn=cmd_validate)
312
+
313
+ s = sub.add_parser("serve",
314
+ help="reference MCP runtime (requires [mcp] extra)")
315
+ s.add_argument("source", help="OpenAPI spec (.yaml/.json) or WSDL path/URL")
316
+ s.add_argument("--transport", choices=["stdio", "http"], default="stdio")
317
+ s.add_argument("--host", default="0.0.0.0")
318
+ s.add_argument("--port", type=int, default=8000)
319
+ s.add_argument("--path", default="/mcp")
320
+ s.add_argument("--validate-output", action="store_true",
321
+ help="validate tool output against the response schema")
322
+ _add_bridge_args(s)
323
+ s.set_defaults(fn=cmd_serve)
324
+
325
+ args = ap.parse_args(argv)
326
+ return args.fn(args)
327
+
328
+
329
+ if __name__ == "__main__":
330
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
1
+ """Core conversion API (no MCP/httpx dependencies).
2
+
3
+ spec = spec2openapi.convert_wsdl("https://host/service?wsdl")
4
+ spec2openapi.dump_spec(spec) # yaml/json text
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .openapi import build_spec, dump_spec # noqa: F401 (re-exported)
13
+ from .parser import parse_wsdl
14
+
15
+
16
+ def convert_wsdl(
17
+ source: str,
18
+ *,
19
+ title: str | None = None,
20
+ version: str = "1.0.0",
21
+ base_path: str = "/operations",
22
+ service: str | None = None,
23
+ port: str | None = None,
24
+ prefer_soap12: bool = False,
25
+ strict: bool = False,
26
+ openapi_version: str = "3.0",
27
+ forbid_external: bool = False,
28
+ huge_tree: bool = False,
29
+ ) -> dict[str, Any]:
30
+ """WSDL (path/URL) -> OpenAPI dict with x-soap extensions.
31
+
32
+ Set forbid_external=True when the WSDL comes from an untrusted source
33
+ (refuses to fetch remote wsdl:/xsd: imports).
34
+ """
35
+ parsed = parse_wsdl(
36
+ source, service=service, port=port,
37
+ prefer_soap12=prefer_soap12, strict=strict,
38
+ forbid_external=forbid_external, huge_tree=huge_tree,
39
+ )
40
+ return build_spec(
41
+ parsed, title=title, version=version,
42
+ base_path=base_path, openapi_version=openapi_version,
43
+ )
44
+
45
+
46
+ def load_spec(path: str | Path) -> dict[str, Any]:
47
+ """Load an OpenAPI spec from a .yaml/.yml/.json file."""
48
+ p = Path(path)
49
+ text = p.read_text(encoding="utf-8")
50
+ if p.suffix.lower() == ".json" or text.lstrip().startswith("{"):
51
+ return json.loads(text)
52
+ import yaml
53
+
54
+ return yaml.safe_load(text)
55
+
56
+
57
+ def spec_has_soap(spec: dict[str, Any]) -> bool:
58
+ for item in spec.get("paths", {}).values():
59
+ for method in (item or {}).values():
60
+ if isinstance(method, dict) and method.get("x-soap"):
61
+ return True
62
+ return False
@@ -0,0 +1,234 @@
1
+ """Assemble the parsed WSDL model into an OpenAPI spec (3.0 or 3.1).
2
+
3
+ The spec is a valid, ordinary OpenAPI document that FastMCP's
4
+ `from_openapi()` can consume directly. SOAP binding metadata is embedded in
5
+ vendor extensions:
6
+
7
+ - root `x-soap` : wsdl source, generator info, skipped operations
8
+ - op `x-soap` : soapAction, soapVersion, style, endpoint, wrapper
9
+ element QNames, soap:header parts, declared faults
10
+ - schemas carry OpenAPI `xml` annotations (name / namespace / attribute /
11
+ x-text) which a SOAP call layer uses to serialize JSON <-> literal XML.
12
+
13
+ Property order inside `properties` mirrors the XSD sequence order and MUST
14
+ be preserved (do not alphabetize the document).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+ from urllib.parse import urlparse
20
+
21
+ from . import __version__ as _version
22
+ from .parser import ParsedWsdl
23
+ import re
24
+
25
+ from .schema import SchemaConverter, sanitize_name
26
+
27
+ _TOOL_ID_RE = re.compile(r"[^A-Za-z0-9_]+")
28
+
29
+ SOAP_FAULT_SCHEMA = {
30
+ "type": "object",
31
+ "description": "SOAP Fault mapped to a JSON error payload.",
32
+ "properties": {
33
+ "faultcode": {"type": "string"},
34
+ "faultstring": {"type": "string"},
35
+ "detail": {"type": "string"},
36
+ },
37
+ }
38
+
39
+
40
+ def _origin(url: str) -> str:
41
+ try:
42
+ p = urlparse(url)
43
+ if p.scheme and p.netloc:
44
+ return f"{p.scheme}://{p.netloc}"
45
+ except Exception:
46
+ pass
47
+ return url or "http://localhost"
48
+
49
+
50
+ def _element_qname(element: Any) -> dict[str, Any]:
51
+ qname = getattr(element, "qname", None)
52
+ if qname is None:
53
+ return {"element": getattr(element, "name", ""), "namespace": None}
54
+ return {"element": qname.localname, "namespace": qname.namespace}
55
+
56
+
57
+ def build_spec(
58
+ parsed: ParsedWsdl,
59
+ *,
60
+ title: str | None = None,
61
+ version: str = "1.0.0",
62
+ base_path: str = "/operations",
63
+ openapi_version: str = "3.0",
64
+ ) -> dict[str, Any]:
65
+ conv = SchemaConverter(parsed.xsd_meta)
66
+ paths: dict[str, Any] = {}
67
+
68
+ for op in parsed.operations:
69
+ # FastMCP normalizes tool names to [A-Za-z0-9_]; emit operationIds
70
+ # in that alphabet so tool name == operationId after the round-trip
71
+ op_id = _TOOL_ID_RE.sub("_", sanitize_name(op.op_id)).strip("_")
72
+ in_q = _element_qname(op.input_element)
73
+ in_schema = conv.element_type_to_object_schema(
74
+ op.input_element.type,
75
+ hint=f"{op_id}Input",
76
+ qkey=(in_q["namespace"] or "", in_q["element"]),
77
+ )
78
+ has_params = bool(in_schema.get("properties"))
79
+
80
+ if op.output_element is not None:
81
+ out_q = _element_qname(op.output_element)
82
+ out_schema = conv.element_type_to_object_schema(
83
+ op.output_element.type,
84
+ hint=f"{op_id}Output",
85
+ qkey=(out_q["namespace"] or "", out_q["element"]),
86
+ )
87
+ else:
88
+ out_schema = {"type": "object"}
89
+
90
+ x_soap: dict[str, Any] = {
91
+ "operation": op.name,
92
+ "service": op.service,
93
+ "port": op.port,
94
+ "soapAction": op.soap_action,
95
+ "soapVersion": op.soap_version,
96
+ "style": op.style,
97
+ "endpoint": op.endpoint,
98
+ "input": _element_qname(op.input_element),
99
+ }
100
+ if op.output_element is not None:
101
+ x_soap["output"] = _element_qname(op.output_element)
102
+
103
+ doc_lines = [
104
+ op.documentation
105
+ or f"SOAP operation {op.name} of service {op.service}."
106
+ ]
107
+
108
+ if op.headers:
109
+ hmeta = []
110
+ for h in op.headers:
111
+ comp = conv.register_element_component(
112
+ h.element, hint=f"{op_id}Header_{h.part}"
113
+ )
114
+ entry = _element_qname(h.element)
115
+ entry["part"] = h.part
116
+ entry["schema"] = f"#/components/schemas/{comp}"
117
+ hmeta.append(entry)
118
+ x_soap["headers"] = hmeta
119
+ names = ", ".join(h["element"] for h in hmeta)
120
+ doc_lines.append(
121
+ f"Requires SOAP header(s): {names} "
122
+ "(supplied by the runtime, not by tool arguments)."
123
+ )
124
+
125
+ if op.faults:
126
+ fmeta = []
127
+ fault_names = []
128
+ for f in op.faults:
129
+ entry: dict[str, Any] = {"name": f.name}
130
+ if f.element is not None:
131
+ entry.update(_element_qname(f.element))
132
+ comp = conv.register_element_component(
133
+ f.element, hint=f"{op_id}Fault_{f.name}"
134
+ )
135
+ entry["schema"] = f"#/components/schemas/{comp}"
136
+ fmeta.append(entry)
137
+ fault_names.append(f.name)
138
+ x_soap["faults"] = fmeta
139
+ doc_lines.append(f"Declared faults: {', '.join(fault_names)}.")
140
+
141
+ description = "\n".join(doc_lines)
142
+ post: dict[str, Any] = {
143
+ "operationId": op_id,
144
+ "summary": doc_lines[0].splitlines()[0][:120],
145
+ "description": description,
146
+ "tags": [op.service],
147
+ "x-soap": x_soap,
148
+ "requestBody": {
149
+ "required": has_params,
150
+ "content": {"application/json": {"schema": in_schema}},
151
+ },
152
+ "responses": {
153
+ "200": {
154
+ "description": f"Result of SOAP operation {op.name}",
155
+ "content": {"application/json": {"schema": out_schema}},
156
+ },
157
+ "500": {
158
+ "description": "SOAP Fault"
159
+ + (
160
+ f" (declared: {', '.join(f.name for f in op.faults)})"
161
+ if op.faults
162
+ else ""
163
+ ),
164
+ "content": {
165
+ "application/json": {
166
+ "schema": {"$ref": "#/components/schemas/SoapFault"}
167
+ }
168
+ },
169
+ },
170
+ },
171
+ }
172
+ paths[f"{base_path}/{op_id}"] = {"post": post}
173
+
174
+ endpoint = parsed.operations[0].endpoint if parsed.operations else ""
175
+ components = dict(conv.components)
176
+ components["SoapFault"] = SOAP_FAULT_SCHEMA
177
+
178
+ spec: dict[str, Any] = {
179
+ "openapi": "3.0.3",
180
+ "info": {
181
+ "title": title or parsed.name,
182
+ "version": version,
183
+ "description": parsed.documentation
184
+ or f"Generated from WSDL by spec2openapi {_version}. "
185
+ f"Each path is a SOAP operation exposed as a JSON call.",
186
+ },
187
+ "servers": [{"url": _origin(endpoint)}],
188
+ "paths": paths,
189
+ "components": {"schemas": components},
190
+ "x-soap": {
191
+ "wsdl": parsed.source,
192
+ "generator": f"spec2openapi/{_version}",
193
+ "skippedOperations": [
194
+ {"operation": o, "reason": r} for o, r in parsed.skipped
195
+ ],
196
+ },
197
+ }
198
+ if openapi_version.startswith("3.1"):
199
+ spec = to_openapi_31(spec)
200
+ return spec
201
+
202
+
203
+ def to_openapi_31(spec: dict[str, Any]) -> dict[str, Any]:
204
+ """Convert the generated 3.0 document to OpenAPI 3.1 JSON Schema style."""
205
+
206
+ def walk(node: Any) -> Any:
207
+ if isinstance(node, list):
208
+ return [walk(v) for v in node]
209
+ if not isinstance(node, dict):
210
+ return node
211
+ node = {k: walk(v) for k, v in node.items()}
212
+ if node.pop("nullable", False):
213
+ t = node.get("type")
214
+ if isinstance(t, str):
215
+ node["type"] = [t, "null"]
216
+ if node.get("exclusiveMinimum") is True and "minimum" in node:
217
+ node["exclusiveMinimum"] = node.pop("minimum")
218
+ if node.get("exclusiveMaximum") is True and "maximum" in node:
219
+ node["exclusiveMaximum"] = node.pop("maximum")
220
+ return node
221
+
222
+ out = walk(dict(spec))
223
+ out["openapi"] = "3.1.0"
224
+ return out
225
+
226
+
227
+ def dump_spec(spec: dict[str, Any], fmt: str = "yaml") -> str:
228
+ if fmt == "json":
229
+ import json
230
+
231
+ return json.dumps(spec, indent=2, ensure_ascii=False)
232
+ import yaml
233
+
234
+ return yaml.safe_dump(spec, sort_keys=False, allow_unicode=True, width=100)