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.
@@ -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)