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/__init__.py +55 -0
- spec2openapi/bridge.py +478 -0
- spec2openapi/cli.py +330 -0
- spec2openapi/convert.py +62 -0
- spec2openapi/openapi.py +234 -0
- spec2openapi/parser.py +403 -0
- spec2openapi/schema.py +398 -0
- spec2openapi/server.py +85 -0
- spec2openapi/swagger.py +430 -0
- spec2openapi-0.1.0.dist-info/METADATA +209 -0
- spec2openapi-0.1.0.dist-info/RECORD +16 -0
- spec2openapi-0.1.0.dist-info/WHEEL +5 -0
- spec2openapi-0.1.0.dist-info/entry_points.txt +2 -0
- spec2openapi-0.1.0.dist-info/licenses/LICENSE +202 -0
- spec2openapi-0.1.0.dist-info/licenses/NOTICE +2 -0
- spec2openapi-0.1.0.dist-info/top_level.txt +1 -0
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())
|
spec2openapi/convert.py
ADDED
|
@@ -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
|
spec2openapi/openapi.py
ADDED
|
@@ -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)
|