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/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""spec2openapi: SOAP/WSDL -> FastMCP-ready OpenAPI specs.
|
|
2
|
+
|
|
3
|
+
Core API (no MCP dependencies): convert_wsdl, load_spec, dump_spec,
|
|
4
|
+
spec_has_soap, parse_wsdl, build_spec.
|
|
5
|
+
|
|
6
|
+
Optional MCP runtime (pip install 'spec2openapi[mcp]'): from_openapi_spec,
|
|
7
|
+
from_wsdl, BridgeOptions, SoapBridgeTransport.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .convert import convert_wsdl, load_spec, spec_has_soap # noqa: E402,F401
|
|
13
|
+
from .openapi import build_spec, dump_spec, to_openapi_31 # noqa: E402,F401
|
|
14
|
+
from .parser import parse_wsdl # noqa: E402,F401
|
|
15
|
+
from .swagger import convert_swagger, is_swagger2 # noqa: E402,F401
|
|
16
|
+
|
|
17
|
+
_MCP_ATTRS = {
|
|
18
|
+
"from_openapi_spec": "server",
|
|
19
|
+
"from_wsdl": "server",
|
|
20
|
+
"BridgeOptions": "bridge",
|
|
21
|
+
"SoapBridgeTransport": "bridge",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"convert_wsdl",
|
|
27
|
+
"load_spec",
|
|
28
|
+
"dump_spec",
|
|
29
|
+
"spec_has_soap",
|
|
30
|
+
"parse_wsdl",
|
|
31
|
+
"build_spec",
|
|
32
|
+
"to_openapi_31",
|
|
33
|
+
"convert_swagger",
|
|
34
|
+
"is_swagger2",
|
|
35
|
+
# lazily loaded, require the [mcp] extra:
|
|
36
|
+
"from_openapi_spec",
|
|
37
|
+
"from_wsdl",
|
|
38
|
+
"BridgeOptions",
|
|
39
|
+
"SoapBridgeTransport",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def __getattr__(name: str):
|
|
44
|
+
if name in _MCP_ATTRS:
|
|
45
|
+
import importlib
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
module = importlib.import_module(f".{_MCP_ATTRS[name]}", __name__)
|
|
49
|
+
except ImportError as exc:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
f"spec2openapi.{name} requires optional dependencies; "
|
|
52
|
+
"install them with: pip install 'spec2openapi[mcp]'"
|
|
53
|
+
) from exc
|
|
54
|
+
return getattr(module, name)
|
|
55
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
spec2openapi/bridge.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""SOAP bridge: an httpx transport that turns REST/JSON calls generated by
|
|
2
|
+
FastMCP's OpenAPI integration into literal SOAP calls.
|
|
3
|
+
|
|
4
|
+
FastMCP tool call -> POST {base}/operations/{op} (JSON)
|
|
5
|
+
-> [this transport] JSON -> SOAP envelope (metadata from x-soap + xml
|
|
6
|
+
annotations in the spec) -> real SOAP endpoint -> XML response ->
|
|
7
|
+
JSON (shaped by the response schema) -> httpx.Response(200, json)
|
|
8
|
+
|
|
9
|
+
The container image stays fixed: swapping the OpenAPI spec (ConfigMap) is
|
|
10
|
+
enough to expose a different SOAP service as MCP.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from lxml import etree
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("spec2openapi")
|
|
24
|
+
|
|
25
|
+
SOAP_ENV_NS = {
|
|
26
|
+
"1.1": "http://schemas.xmlsoap.org/soap/envelope/",
|
|
27
|
+
"1.2": "http://www.w3.org/2003/05/soap-envelope",
|
|
28
|
+
}
|
|
29
|
+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
30
|
+
WSSE_NS = (
|
|
31
|
+
"http://docs.oasis-open.org/wss/2004/01/"
|
|
32
|
+
"oasis-200401-wss-wssecurity-secext-1.0.xsd"
|
|
33
|
+
)
|
|
34
|
+
PASSWORD_TEXT = (
|
|
35
|
+
"http://docs.oasis-open.org/wss/2004/01/"
|
|
36
|
+
"oasis-200401-wss-username-token-profile-1.0#PasswordText"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclasses.dataclass
|
|
41
|
+
class BridgeOptions:
|
|
42
|
+
"""Runtime options, typically injected via env vars in the container."""
|
|
43
|
+
|
|
44
|
+
endpoint: str | None = None # override the WSDL-declared endpoint
|
|
45
|
+
username: str | None = None
|
|
46
|
+
password: str | None = None
|
|
47
|
+
auth: str | None = None # None | "basic" | "wsse"
|
|
48
|
+
timeout: float = 30.0
|
|
49
|
+
verify: bool = True
|
|
50
|
+
headers: dict[str, str] | None = None
|
|
51
|
+
trust_env: bool = True # honor HTTP(S)_PROXY / NO_PROXY env vars
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_env(cls) -> "BridgeOptions":
|
|
55
|
+
return cls(
|
|
56
|
+
endpoint=os.getenv("SPEC2OPENAPI_ENDPOINT") or None,
|
|
57
|
+
username=os.getenv("SPEC2OPENAPI_USERNAME") or None,
|
|
58
|
+
password=os.getenv("SPEC2OPENAPI_PASSWORD") or None,
|
|
59
|
+
auth=os.getenv("SPEC2OPENAPI_AUTH") or None,
|
|
60
|
+
timeout=float(os.getenv("SPEC2OPENAPI_TIMEOUT", "30")),
|
|
61
|
+
verify=os.getenv("SPEC2OPENAPI_VERIFY", "1") not in ("0", "false", "False"),
|
|
62
|
+
trust_env=os.getenv("SPEC2OPENAPI_TRUST_ENV", "1")
|
|
63
|
+
not in ("0", "false", "False"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
# spec indexing helpers
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _SpecIndex:
|
|
73
|
+
def __init__(self, spec: dict[str, Any]):
|
|
74
|
+
self.spec = spec
|
|
75
|
+
self.components: dict[str, Any] = spec.get("components", {}).get("schemas", {})
|
|
76
|
+
self.ops: dict[str, dict[str, Any]] = {}
|
|
77
|
+
for path, item in spec.get("paths", {}).items():
|
|
78
|
+
post = (item or {}).get("post") or {}
|
|
79
|
+
xsoap = post.get("x-soap")
|
|
80
|
+
if not xsoap:
|
|
81
|
+
continue
|
|
82
|
+
req = (
|
|
83
|
+
post.get("requestBody", {})
|
|
84
|
+
.get("content", {})
|
|
85
|
+
.get("application/json", {})
|
|
86
|
+
.get("schema", {})
|
|
87
|
+
)
|
|
88
|
+
out = (
|
|
89
|
+
post.get("responses", {})
|
|
90
|
+
.get("200", {})
|
|
91
|
+
.get("content", {})
|
|
92
|
+
.get("application/json", {})
|
|
93
|
+
.get("schema", {})
|
|
94
|
+
)
|
|
95
|
+
self.ops[path] = {"x-soap": xsoap, "input": req, "output": out}
|
|
96
|
+
|
|
97
|
+
def deref(self, schema: dict[str, Any] | None) -> dict[str, Any]:
|
|
98
|
+
"""Resolve $ref / single-allOf wrappers, keeping xml annotations."""
|
|
99
|
+
if not schema:
|
|
100
|
+
return {}
|
|
101
|
+
seen = 0
|
|
102
|
+
xml = schema.get("xml")
|
|
103
|
+
while seen < 32:
|
|
104
|
+
if "$ref" in schema:
|
|
105
|
+
name = schema["$ref"].rsplit("/", 1)[-1]
|
|
106
|
+
schema = self.components.get(name, {})
|
|
107
|
+
elif "allOf" in schema and len(schema["allOf"]) == 1:
|
|
108
|
+
inner = schema["allOf"][0]
|
|
109
|
+
merged = dict(inner)
|
|
110
|
+
for k, v in schema.items():
|
|
111
|
+
if k != "allOf":
|
|
112
|
+
merged.setdefault(k, v)
|
|
113
|
+
schema = merged
|
|
114
|
+
else:
|
|
115
|
+
break
|
|
116
|
+
seen += 1
|
|
117
|
+
if xml and "xml" not in schema:
|
|
118
|
+
schema = dict(schema)
|
|
119
|
+
schema["xml"] = xml
|
|
120
|
+
return schema
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------
|
|
124
|
+
# JSON -> SOAP envelope
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _to_text(value: Any) -> str:
|
|
129
|
+
if isinstance(value, bool):
|
|
130
|
+
return "true" if value else "false"
|
|
131
|
+
return str(value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _write_value(parent: etree._Element, tag: etree.QName | str, schema: dict,
|
|
135
|
+
value: Any, index: _SpecIndex) -> None:
|
|
136
|
+
el = etree.SubElement(parent, tag)
|
|
137
|
+
if value is None:
|
|
138
|
+
el.set(etree.QName(XSI_NS, "nil"), "true")
|
|
139
|
+
return
|
|
140
|
+
schema = index.deref(schema)
|
|
141
|
+
if schema.get("type") == "object" or "properties" in schema:
|
|
142
|
+
_write_object(el, schema, value, index)
|
|
143
|
+
elif isinstance(value, (dict,)):
|
|
144
|
+
_write_object(el, schema, value, index)
|
|
145
|
+
else:
|
|
146
|
+
el.text = _to_text(value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _write_object(parent: etree._Element, schema: dict, data: Any,
|
|
150
|
+
index: _SpecIndex) -> None:
|
|
151
|
+
schema = index.deref(schema)
|
|
152
|
+
props: dict[str, Any] = schema.get("properties", {})
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
parent.text = _to_text(data)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# attributes first
|
|
158
|
+
for name, pschema in props.items():
|
|
159
|
+
pd = index.deref(pschema)
|
|
160
|
+
xml = pd.get("xml") or pschema.get("xml") or {}
|
|
161
|
+
if xml.get("attribute") and name in data and data[name] is not None:
|
|
162
|
+
parent.set(xml.get("name", name), _to_text(data[name]))
|
|
163
|
+
|
|
164
|
+
# elements in schema (=XSD sequence) order
|
|
165
|
+
for name, pschema in props.items():
|
|
166
|
+
pd = index.deref(pschema)
|
|
167
|
+
xml = pd.get("xml") or pschema.get("xml") or {}
|
|
168
|
+
if xml.get("attribute"):
|
|
169
|
+
continue
|
|
170
|
+
if name not in data:
|
|
171
|
+
continue
|
|
172
|
+
value = data[name]
|
|
173
|
+
if xml.get("x-text"): # xsd:simpleContent text value
|
|
174
|
+
parent.text = _to_text(value) if value is not None else None
|
|
175
|
+
continue
|
|
176
|
+
local = xml.get("name", name)
|
|
177
|
+
ns = xml.get("namespace")
|
|
178
|
+
tag = etree.QName(ns, local) if ns else local
|
|
179
|
+
if pd.get("type") == "array":
|
|
180
|
+
items_schema = pd.get("items", {})
|
|
181
|
+
for item in value if isinstance(value, list) else [value]:
|
|
182
|
+
_write_value(parent, tag, items_schema, item, index)
|
|
183
|
+
else:
|
|
184
|
+
_write_value(parent, tag, pd, value, index)
|
|
185
|
+
|
|
186
|
+
for name in data:
|
|
187
|
+
if name not in props:
|
|
188
|
+
logger.debug("payload key %r not in schema; ignored", name)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_envelope(op: dict[str, Any], payload: dict[str, Any],
|
|
192
|
+
index: _SpecIndex, options: BridgeOptions) -> bytes:
|
|
193
|
+
xsoap = op["x-soap"]
|
|
194
|
+
env_ns = SOAP_ENV_NS.get(xsoap.get("soapVersion", "1.1"), SOAP_ENV_NS["1.1"])
|
|
195
|
+
nsmap = {"soapenv": env_ns}
|
|
196
|
+
envelope = etree.Element(etree.QName(env_ns, "Envelope"), nsmap=nsmap)
|
|
197
|
+
|
|
198
|
+
if options.auth == "wsse" and options.username is not None:
|
|
199
|
+
header = etree.SubElement(envelope, etree.QName(env_ns, "Header"))
|
|
200
|
+
security = etree.SubElement(
|
|
201
|
+
header, etree.QName(WSSE_NS, "Security"), nsmap={"wsse": WSSE_NS}
|
|
202
|
+
)
|
|
203
|
+
security.set(etree.QName(env_ns, "mustUnderstand"), "1")
|
|
204
|
+
token = etree.SubElement(security, etree.QName(WSSE_NS, "UsernameToken"))
|
|
205
|
+
user = etree.SubElement(token, etree.QName(WSSE_NS, "Username"))
|
|
206
|
+
user.text = options.username
|
|
207
|
+
pwd = etree.SubElement(token, etree.QName(WSSE_NS, "Password"))
|
|
208
|
+
pwd.set("Type", PASSWORD_TEXT)
|
|
209
|
+
pwd.text = options.password or ""
|
|
210
|
+
|
|
211
|
+
body = etree.SubElement(envelope, etree.QName(env_ns, "Body"))
|
|
212
|
+
in_meta = xsoap.get("input", {})
|
|
213
|
+
ns = in_meta.get("namespace")
|
|
214
|
+
local = in_meta.get("element", xsoap.get("operation", "Request"))
|
|
215
|
+
# use a *prefixed* namespace: unqualified children (rpc parts,
|
|
216
|
+
# elementFormDefault="unqualified") must not inherit a default ns
|
|
217
|
+
root_nsmap = {"tns": ns} if ns else None
|
|
218
|
+
root = etree.SubElement(
|
|
219
|
+
body, etree.QName(ns, local) if ns else local, nsmap=root_nsmap
|
|
220
|
+
)
|
|
221
|
+
_write_object(root, index.deref(op.get("input", {})), payload or {}, index)
|
|
222
|
+
|
|
223
|
+
return etree.tostring(envelope, xml_declaration=True, encoding="utf-8")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------------------
|
|
227
|
+
# SOAP response -> JSON
|
|
228
|
+
# --------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _localname(el: etree._Element) -> str:
|
|
232
|
+
try:
|
|
233
|
+
return etree.QName(el).localname
|
|
234
|
+
except Exception:
|
|
235
|
+
return str(el.tag)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _coerce(text: str | None, schema: dict[str, Any]) -> Any:
|
|
239
|
+
if text is None:
|
|
240
|
+
return None
|
|
241
|
+
t = schema.get("type")
|
|
242
|
+
s = text.strip()
|
|
243
|
+
try:
|
|
244
|
+
if t == "integer":
|
|
245
|
+
return int(s)
|
|
246
|
+
if t == "number":
|
|
247
|
+
return float(s)
|
|
248
|
+
if t == "boolean":
|
|
249
|
+
return s in ("true", "1")
|
|
250
|
+
except ValueError:
|
|
251
|
+
return text
|
|
252
|
+
return text if t == "string" else (text if s == "" else s)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _read_object(el: etree._Element, schema: dict[str, Any],
|
|
256
|
+
index: _SpecIndex) -> Any:
|
|
257
|
+
schema = index.deref(schema)
|
|
258
|
+
props: dict[str, Any] = schema.get("properties", {})
|
|
259
|
+
if not props:
|
|
260
|
+
# no schema knowledge: return text or shallow dict of children
|
|
261
|
+
children = [c for c in el if isinstance(c.tag, str)]
|
|
262
|
+
if not children:
|
|
263
|
+
return el.text
|
|
264
|
+
out: dict[str, Any] = {}
|
|
265
|
+
for c in children:
|
|
266
|
+
name = _localname(c)
|
|
267
|
+
val = _read_object(c, {}, index)
|
|
268
|
+
if name in out:
|
|
269
|
+
if not isinstance(out[name], list):
|
|
270
|
+
out[name] = [out[name]]
|
|
271
|
+
out[name].append(val)
|
|
272
|
+
else:
|
|
273
|
+
out[name] = val
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
result: dict[str, Any] = {}
|
|
277
|
+
children = [c for c in el if isinstance(c.tag, str)]
|
|
278
|
+
by_name: dict[str, list[etree._Element]] = {}
|
|
279
|
+
for c in children:
|
|
280
|
+
by_name.setdefault(_localname(c), []).append(c)
|
|
281
|
+
|
|
282
|
+
for name, pschema in props.items():
|
|
283
|
+
pd = index.deref(pschema)
|
|
284
|
+
xml = pd.get("xml") or (pschema.get("xml") if isinstance(pschema, dict) else None) or {}
|
|
285
|
+
local = xml.get("name", name)
|
|
286
|
+
if xml.get("attribute"):
|
|
287
|
+
v = el.get(local)
|
|
288
|
+
if v is not None:
|
|
289
|
+
result[name] = _coerce(v, pd)
|
|
290
|
+
continue
|
|
291
|
+
if xml.get("x-text"): # xsd:simpleContent text value
|
|
292
|
+
result[name] = _coerce(el.text, pd)
|
|
293
|
+
continue
|
|
294
|
+
nodes = by_name.get(local, [])
|
|
295
|
+
if pd.get("type") == "array":
|
|
296
|
+
items_schema = index.deref(pd.get("items", {}))
|
|
297
|
+
result[name] = [_read_node(n, items_schema, index) for n in nodes]
|
|
298
|
+
elif nodes:
|
|
299
|
+
result[name] = _read_node(nodes[0], pd, index)
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _read_node(node: etree._Element, schema: dict[str, Any],
|
|
304
|
+
index: _SpecIndex) -> Any:
|
|
305
|
+
if node.get(etree.QName(XSI_NS, "nil")) in ("true", "1"):
|
|
306
|
+
return None
|
|
307
|
+
schema = index.deref(schema)
|
|
308
|
+
if schema.get("type") == "object" or "properties" in schema:
|
|
309
|
+
return _read_object(node, schema, index)
|
|
310
|
+
return _coerce(node.text, schema)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_fault(body: etree._Element, env_ns: str) -> dict[str, Any] | None:
|
|
314
|
+
fault = body.find(f"{{{env_ns}}}Fault")
|
|
315
|
+
if fault is None:
|
|
316
|
+
return None
|
|
317
|
+
if env_ns == SOAP_ENV_NS["1.2"]:
|
|
318
|
+
code = fault.findtext(f"{{{env_ns}}}Code/{{{env_ns}}}Value") or ""
|
|
319
|
+
reason = fault.findtext(f"{{{env_ns}}}Reason/{{{env_ns}}}Text") or ""
|
|
320
|
+
detail_el = fault.find(f"{{{env_ns}}}Detail")
|
|
321
|
+
else:
|
|
322
|
+
code = fault.findtext("faultcode") or ""
|
|
323
|
+
reason = fault.findtext("faultstring") or ""
|
|
324
|
+
detail_el = fault.find("detail")
|
|
325
|
+
detail = ""
|
|
326
|
+
if detail_el is not None:
|
|
327
|
+
detail = etree.tostring(detail_el, encoding="unicode")
|
|
328
|
+
return {"faultcode": code, "faultstring": reason, "detail": detail}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def parse_response(content: bytes, op: dict[str, Any],
|
|
332
|
+
index: _SpecIndex) -> tuple[int, dict[str, Any]]:
|
|
333
|
+
"""Returns (http_status, json_payload)."""
|
|
334
|
+
xsoap = op["x-soap"]
|
|
335
|
+
env_ns = SOAP_ENV_NS.get(xsoap.get("soapVersion", "1.1"), SOAP_ENV_NS["1.1"])
|
|
336
|
+
try:
|
|
337
|
+
# endpoint responses are untrusted: no entities, DTDs, or network
|
|
338
|
+
parser = etree.XMLParser(resolve_entities=False, load_dtd=False,
|
|
339
|
+
no_network=True, huge_tree=False)
|
|
340
|
+
tree = etree.fromstring(content, parser=parser)
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
return 502, {
|
|
343
|
+
"faultcode": "spec2openapi.InvalidXML",
|
|
344
|
+
"faultstring": f"endpoint returned non-XML response: {exc}",
|
|
345
|
+
"detail": content[:2000].decode("utf-8", "replace"),
|
|
346
|
+
}
|
|
347
|
+
body = tree.find(f"{{{env_ns}}}Body")
|
|
348
|
+
if body is None:
|
|
349
|
+
# tolerate the other soap version's namespace
|
|
350
|
+
for alt in SOAP_ENV_NS.values():
|
|
351
|
+
body = tree.find(f"{{{alt}}}Body")
|
|
352
|
+
if body is not None:
|
|
353
|
+
env_ns = alt
|
|
354
|
+
break
|
|
355
|
+
if body is None:
|
|
356
|
+
return 502, {
|
|
357
|
+
"faultcode": "spec2openapi.NoBody",
|
|
358
|
+
"faultstring": "no SOAP Body found in response",
|
|
359
|
+
"detail": "",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fault = parse_fault(body, env_ns)
|
|
363
|
+
if fault is not None:
|
|
364
|
+
return 500, fault
|
|
365
|
+
|
|
366
|
+
out_meta = xsoap.get("output") or {}
|
|
367
|
+
expected = out_meta.get("element")
|
|
368
|
+
root = None
|
|
369
|
+
for c in body:
|
|
370
|
+
if not isinstance(c.tag, str):
|
|
371
|
+
continue
|
|
372
|
+
if expected is None or _localname(c) == expected:
|
|
373
|
+
root = c
|
|
374
|
+
break
|
|
375
|
+
if root is None:
|
|
376
|
+
root = next((c for c in body if isinstance(c.tag, str)), None)
|
|
377
|
+
if root is None:
|
|
378
|
+
return 200, {}
|
|
379
|
+
|
|
380
|
+
out_schema = index.deref(op.get("output", {}))
|
|
381
|
+
data = _read_object(root, out_schema, index)
|
|
382
|
+
if not isinstance(data, dict):
|
|
383
|
+
data = {"value": data}
|
|
384
|
+
return 200, data
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# --------------------------------------------------------------------------
|
|
388
|
+
# the transport
|
|
389
|
+
# --------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class SoapBridgeTransport(httpx.AsyncBaseTransport):
|
|
393
|
+
"""httpx transport that answers FastMCP's REST calls by calling SOAP."""
|
|
394
|
+
|
|
395
|
+
def __init__(self, spec: dict[str, Any], options: BridgeOptions | None = None):
|
|
396
|
+
self.index = _SpecIndex(spec)
|
|
397
|
+
self.options = options or BridgeOptions.from_env()
|
|
398
|
+
auth = None
|
|
399
|
+
if self.options.auth == "basic" and self.options.username is not None:
|
|
400
|
+
auth = (self.options.username, self.options.password or "")
|
|
401
|
+
self._client = httpx.AsyncClient(
|
|
402
|
+
auth=auth,
|
|
403
|
+
timeout=self.options.timeout,
|
|
404
|
+
verify=self.options.verify,
|
|
405
|
+
headers=self.options.headers or {},
|
|
406
|
+
trust_env=self.options.trust_env,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
async def aclose(self) -> None:
|
|
410
|
+
await self._client.aclose()
|
|
411
|
+
|
|
412
|
+
def _json_response(self, request: httpx.Request, status: int,
|
|
413
|
+
payload: dict[str, Any]) -> httpx.Response:
|
|
414
|
+
return httpx.Response(status, json=payload, request=request)
|
|
415
|
+
|
|
416
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
417
|
+
path = request.url.path
|
|
418
|
+
op = self.index.ops.get(path)
|
|
419
|
+
if op is None:
|
|
420
|
+
return self._json_response(
|
|
421
|
+
request, 404,
|
|
422
|
+
{"faultcode": "spec2openapi.UnknownOperation",
|
|
423
|
+
"faultstring": f"no x-soap operation registered for path {path}",
|
|
424
|
+
"detail": ""},
|
|
425
|
+
)
|
|
426
|
+
raw = request.read()
|
|
427
|
+
try:
|
|
428
|
+
payload = json.loads(raw) if raw else {}
|
|
429
|
+
except json.JSONDecodeError as exc:
|
|
430
|
+
return self._json_response(
|
|
431
|
+
request, 400,
|
|
432
|
+
{"faultcode": "spec2openapi.BadRequest",
|
|
433
|
+
"faultstring": f"invalid JSON body: {exc}", "detail": ""},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
xsoap = op["x-soap"]
|
|
437
|
+
endpoint = self.options.endpoint or xsoap.get("endpoint")
|
|
438
|
+
if not endpoint:
|
|
439
|
+
return self._json_response(
|
|
440
|
+
request, 502,
|
|
441
|
+
{"faultcode": "spec2openapi.NoEndpoint",
|
|
442
|
+
"faultstring": "no SOAP endpoint configured "
|
|
443
|
+
"(x-soap.endpoint / SPEC2OPENAPI_ENDPOINT)",
|
|
444
|
+
"detail": ""},
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
envelope = build_envelope(op, payload, self.index, self.options)
|
|
448
|
+
action = xsoap.get("soapAction", "")
|
|
449
|
+
if xsoap.get("soapVersion") == "1.2":
|
|
450
|
+
ct = "application/soap+xml; charset=utf-8"
|
|
451
|
+
if action:
|
|
452
|
+
ct += f'; action="{action}"'
|
|
453
|
+
headers = {"Content-Type": ct}
|
|
454
|
+
else:
|
|
455
|
+
headers = {
|
|
456
|
+
"Content-Type": "text/xml; charset=utf-8",
|
|
457
|
+
"SOAPAction": f'"{action}"',
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
soap_resp = await self._client.post(
|
|
462
|
+
endpoint, content=envelope, headers=headers
|
|
463
|
+
)
|
|
464
|
+
except httpx.HTTPError as exc:
|
|
465
|
+
return self._json_response(
|
|
466
|
+
request, 502,
|
|
467
|
+
{"faultcode": "spec2openapi.TransportError",
|
|
468
|
+
"faultstring": f"SOAP endpoint unreachable: {exc}", "detail": ""},
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
status, data = parse_response(soap_resp.content, op, self.index)
|
|
472
|
+
# a SOAP fault usually arrives as HTTP 500; trust envelope content
|
|
473
|
+
if status == 200 and soap_resp.status_code >= 400 and not data:
|
|
474
|
+
status = 502
|
|
475
|
+
data = {"faultcode": "spec2openapi.HTTPError",
|
|
476
|
+
"faultstring": f"endpoint returned HTTP {soap_resp.status_code}",
|
|
477
|
+
"detail": soap_resp.text[:2000]}
|
|
478
|
+
return self._json_response(request, status, data)
|