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/schema.py ADDED
@@ -0,0 +1,398 @@
1
+ """Convert zeep XSD types into JSON Schema (with OpenAPI `xml` annotations).
2
+
3
+ The generated schemas serve two purposes:
4
+ 1. They describe operation inputs/outputs for FastMCP / OpenAPI tooling
5
+ (descriptions, enums, patterns and bounds improve LLM tool usage).
6
+ 2. Their `xml` annotations carry enough metadata for a SOAP call layer to
7
+ serialize JSON back into a literal XML payload (element names,
8
+ namespaces, attribute markers, text content, ordering = property
9
+ insertion order).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime
14
+ import decimal
15
+ import logging
16
+ import re
17
+ from typing import Any
18
+
19
+ from zeep import xsd as zx
20
+
21
+ from .parser import XsdMeta
22
+
23
+ logger = logging.getLogger("spec2openapi")
24
+
25
+ # format hints derived from zeep builtin class names found in the type's MRO
26
+ _FORMAT_BY_CLS = {
27
+ "DateTime": "date-time",
28
+ "Date": "date",
29
+ "Time": "time",
30
+ "Base64Binary": "byte",
31
+ "AnyURI": "uri",
32
+ "Duration": "duration",
33
+ }
34
+
35
+ _NAME_RE = re.compile(r"[^A-Za-z0-9_.-]")
36
+
37
+
38
+ def sanitize_name(name: str) -> str:
39
+ out = _NAME_RE.sub("_", name or "unnamed")
40
+ return out[:64] or "unnamed"
41
+
42
+
43
+ def _py_to_json_type(py: type) -> dict[str, Any]:
44
+ if py is bool:
45
+ return {"type": "boolean"}
46
+ if py is int:
47
+ return {"type": "integer"}
48
+ if py in (float, decimal.Decimal):
49
+ return {"type": "number"}
50
+ if py is datetime.datetime:
51
+ return {"type": "string", "format": "date-time"}
52
+ if py is datetime.date:
53
+ return {"type": "string", "format": "date"}
54
+ if py is datetime.time:
55
+ return {"type": "string", "format": "time"}
56
+ return {"type": "string"}
57
+
58
+
59
+ def _coerce_default(value: str, schema: dict[str, Any]) -> Any:
60
+ t = schema.get("type")
61
+ try:
62
+ if t == "integer":
63
+ return int(value)
64
+ if t == "number":
65
+ return float(value)
66
+ if t == "boolean":
67
+ return value in ("true", "1")
68
+ except ValueError:
69
+ pass
70
+ return value
71
+
72
+
73
+ def _choice_groups(t: Any) -> list[dict[str, Any]]:
74
+ """Walk zeep's indicator tree to find xsd:choice member groups."""
75
+ groups: list[dict[str, Any]] = []
76
+
77
+ def member_names(indicator) -> list[str]:
78
+ names = []
79
+ for item in indicator:
80
+ if isinstance(item, tuple) and item:
81
+ names.append(str(item[0]))
82
+ else:
83
+ n = getattr(item, "name", None)
84
+ if n:
85
+ names.append(n)
86
+ return names
87
+
88
+ def walk(indicator):
89
+ try:
90
+ items = list(indicator)
91
+ except TypeError:
92
+ return
93
+ for item in items:
94
+ cls = type(item).__name__
95
+ if cls == "Choice":
96
+ names = member_names(item)
97
+ if names:
98
+ try:
99
+ required = int(getattr(item, "min_occurs", 1)) >= 1
100
+ except (TypeError, ValueError):
101
+ required = True
102
+ groups.append({"members": names, "required": required})
103
+ elif cls in ("Sequence", "All", "Group"):
104
+ walk(item)
105
+
106
+ root = getattr(t, "_element", None)
107
+ if root is not None:
108
+ walk(root)
109
+ return groups
110
+
111
+
112
+ class SchemaConverter:
113
+ """Stateful converter that accumulates shared component schemas."""
114
+
115
+ def __init__(self, meta: XsdMeta | None = None):
116
+ self.meta = meta or XsdMeta(facets={}, type_docs={}, child_docs={})
117
+ self.components: dict[str, dict[str, Any]] = {}
118
+ self._registered: dict[int, str] = {} # id(zeep type) -> component name
119
+ self._in_progress: dict[int, str] = {}
120
+
121
+ # -- metadata lookups -------------------------------------------------
122
+
123
+ def _lookup(self, table: dict, ns: str | None, name: str | None):
124
+ if not name:
125
+ return None
126
+ if ns is not None and (ns, name) in table:
127
+ return table[(ns, name)]
128
+ for key, value in table.items():
129
+ if key[-1] == name or key[1] == name:
130
+ if len(key) == 2 and key[1] == name:
131
+ return value
132
+ return None
133
+
134
+ def _child_doc(self, qkey: tuple[str, str] | None, child: str) -> str | None:
135
+ if qkey is None:
136
+ return None
137
+ ns, container = qkey
138
+ doc = self.meta.child_docs.get((ns, container, child))
139
+ if doc:
140
+ return doc
141
+ for (kns, kcont, kchild), value in self.meta.child_docs.items():
142
+ if kcont == container and kchild == child:
143
+ return value
144
+ # inherited elements (xsd:extension): the doc lives on the base type
145
+ for (kns, _kcont, kchild), value in self.meta.child_docs.items():
146
+ if kns == ns and kchild == child:
147
+ return value
148
+ return None
149
+
150
+ def _type_qkey(self, t: Any) -> tuple[str | None, str | None]:
151
+ qname = getattr(t, "qname", None)
152
+ if qname is not None:
153
+ return qname.namespace, qname.localname
154
+ return None, getattr(t, "name", None)
155
+
156
+ # -- public ------------------------------------------------------------
157
+
158
+ def element_type_to_object_schema(
159
+ self, xsd_type: Any, hint: str, qkey: tuple[str, str] | None = None
160
+ ) -> dict[str, Any]:
161
+ """Schema for a wrapper element's complex type, always inlined as an
162
+ object (used for request/response bodies)."""
163
+ if isinstance(xsd_type, zx.ComplexType):
164
+ return self._complex_to_schema(xsd_type, hint, qkey=qkey)
165
+ return {
166
+ "type": "object",
167
+ "properties": {"value": self._simple_to_schema(xsd_type)},
168
+ "required": ["value"],
169
+ }
170
+
171
+ def register_element_component(self, element: Any, hint: str) -> str:
172
+ """Register a standalone element's type (headers, fault details)."""
173
+ t = getattr(element, "type", None)
174
+ if isinstance(t, zx.ComplexType):
175
+ return self._register_complex(t, hint)
176
+ name = sanitize_name(hint)
177
+ if name not in self.components:
178
+ self.components[name] = self._simple_to_schema(t)
179
+ return name
180
+
181
+ # -- internals -----------------------------------------------------------
182
+
183
+ def _type_to_schema(self, xsd_type: Any, hint: str) -> dict[str, Any]:
184
+ if isinstance(xsd_type, zx.ComplexType):
185
+ name = self._register_complex(xsd_type, hint)
186
+ return {"$ref": f"#/components/schemas/{name}"}
187
+ return self._simple_to_schema(xsd_type)
188
+
189
+ def _register_complex(self, t: Any, hint: str) -> str:
190
+ tid = id(t)
191
+ if tid in self._registered:
192
+ return self._registered[tid]
193
+ if tid in self._in_progress: # recursion cycle
194
+ return self._in_progress[tid]
195
+
196
+ base = sanitize_name(getattr(t, "name", None) or hint)
197
+ name = base
198
+ i = 2
199
+ while name in self.components:
200
+ name = f"{base}_{i}"
201
+ i += 1
202
+ self._in_progress[tid] = name
203
+ self.components[name] = {} # reserve slot to keep ordering stable
204
+ try:
205
+ tns, tname = self._type_qkey(t)
206
+ qkey = (tns or "", tname) if tname else None
207
+ self.components[name] = self._complex_to_schema(t, name, qkey=qkey)
208
+ self._registered[tid] = name
209
+ finally:
210
+ self._in_progress.pop(tid, None)
211
+ return name
212
+
213
+ def _complex_to_schema(
214
+ self, t: Any, hint: str, qkey: tuple[str, str] | None = None
215
+ ) -> dict[str, Any]:
216
+ props: dict[str, Any] = {}
217
+ required: list[str] = []
218
+
219
+ elements = list(getattr(t, "elements", []))
220
+ attributes = list(getattr(t, "attributes", []))
221
+
222
+ # xsd:simpleContent: a text value plus attributes
223
+ is_simple_content = (
224
+ len(elements) == 1
225
+ and elements[0][0] == "_value_1"
226
+ and not isinstance(elements[0][1].type, zx.ComplexType)
227
+ )
228
+ if is_simple_content:
229
+ value_schema = self._simple_to_schema(elements[0][1].type)
230
+ value_schema["xml"] = {"x-text": True}
231
+ value_schema.setdefault(
232
+ "description", "Text content of the element."
233
+ )
234
+ props["value"] = value_schema
235
+ required.append("value")
236
+ elements = []
237
+
238
+ for el_name, el in elements:
239
+ if el is None or type(el).__name__ in ("Any", "AnyObject"):
240
+ logger.debug("xsd:any inside %s -> additionalProperties", hint)
241
+ continue
242
+ prop = self._element_to_property(el_name, el, hint, qkey)
243
+ if prop is None:
244
+ continue
245
+ props[el_name] = prop
246
+ min_occurs = getattr(el, "min_occurs", 1)
247
+ try:
248
+ if int(min_occurs) >= 1:
249
+ required.append(el_name)
250
+ except (TypeError, ValueError):
251
+ required.append(el_name)
252
+
253
+ for at_name, attr in attributes:
254
+ if attr is None or type(attr).__name__ == "AnyAttribute":
255
+ continue
256
+ aschema = self._simple_to_schema(getattr(attr, "type", None))
257
+ aschema["xml"] = {"name": at_name, "attribute": True}
258
+ props[at_name] = aschema
259
+ if getattr(attr, "required", False):
260
+ required.append(at_name)
261
+
262
+ schema: dict[str, Any] = {"type": "object", "properties": props}
263
+ if required:
264
+ schema["required"] = required
265
+
266
+ # xsd:choice groups: members must not be required; record the groups
267
+ choices = _choice_groups(t)
268
+ if choices:
269
+ member_set = {m for g in choices for m in g["members"]}
270
+ if "required" in schema:
271
+ schema["required"] = [
272
+ r for r in schema["required"] if r not in member_set
273
+ ]
274
+ if not schema["required"]:
275
+ del schema["required"]
276
+ schema["x-soap-choice"] = choices
277
+ notes = []
278
+ for g in choices:
279
+ kind = "Exactly one" if g["required"] else "At most one"
280
+ notes.append(f"{kind} of: {', '.join(g['members'])}.")
281
+ schema["description"] = " ".join(
282
+ filter(None, [schema.get("description"), *notes])
283
+ )
284
+
285
+ if is_simple_content:
286
+ schema["x-soap-simple-content"] = True
287
+
288
+ # documentation from the raw XSD
289
+ tns, tname = self._type_qkey(t)
290
+ doc = self._lookup(self.meta.type_docs, tns, tname)
291
+ if doc:
292
+ existing = schema.get("description")
293
+ schema["description"] = (
294
+ f"{doc} {existing}" if existing else doc
295
+ )
296
+ return schema
297
+
298
+ def _element_to_property(
299
+ self,
300
+ el_name: str,
301
+ el: Any,
302
+ parent_hint: str,
303
+ qkey: tuple[str, str] | None,
304
+ ) -> dict | None:
305
+ el_type = getattr(el, "type", None)
306
+ if el_type is None:
307
+ return None
308
+
309
+ base = self._type_to_schema(el_type, hint=f"{parent_hint}_{el_name}")
310
+
311
+ qname = getattr(el, "qname", None)
312
+ xml_meta: dict[str, Any] = {"name": el_name}
313
+ if qname is not None and getattr(qname, "namespace", None):
314
+ xml_meta["name"] = qname.localname
315
+ xml_meta["namespace"] = qname.namespace
316
+ if getattr(el, "nillable", False) and "$ref" not in base:
317
+ base["nullable"] = True
318
+
319
+ doc = self._child_doc(qkey, el_name)
320
+
321
+ default = getattr(el, "default", None)
322
+ if default is not None and "$ref" not in base:
323
+ base["default"] = _coerce_default(default, base)
324
+
325
+ max_occurs = getattr(el, "max_occurs", 1)
326
+ is_array = max_occurs == "unbounded"
327
+ if not is_array:
328
+ try:
329
+ is_array = int(max_occurs) > 1
330
+ except (TypeError, ValueError):
331
+ is_array = False
332
+
333
+ if is_array:
334
+ items = base
335
+ if "$ref" not in items:
336
+ items = dict(items)
337
+ items["xml"] = dict(xml_meta)
338
+ arr: dict[str, Any] = {"type": "array", "items": items, "xml": xml_meta}
339
+ try:
340
+ mn = int(getattr(el, "min_occurs", 0))
341
+ if mn > 0:
342
+ arr["minItems"] = mn
343
+ except (TypeError, ValueError):
344
+ pass
345
+ if max_occurs != "unbounded":
346
+ try:
347
+ arr["maxItems"] = int(max_occurs)
348
+ except (TypeError, ValueError):
349
+ pass
350
+ if doc:
351
+ arr["description"] = doc
352
+ return arr
353
+
354
+ if "$ref" in base:
355
+ out: dict[str, Any] = {"allOf": [base], "xml": xml_meta}
356
+ if doc:
357
+ out["description"] = doc
358
+ return out
359
+ base["xml"] = xml_meta
360
+ if doc and "description" not in base:
361
+ base["description"] = doc
362
+ return base
363
+
364
+ def _simple_to_schema(self, t: Any) -> dict[str, Any]:
365
+ if t is None:
366
+ return {"type": "string"}
367
+ cls_names = [k.__name__ for k in type(t).__mro__]
368
+ if type(t).__name__ in ("AnyType", "AnySimpleType"):
369
+ return {}
370
+
371
+ accepted = getattr(t, "accepted_types", None) or []
372
+ py = None
373
+ for cand in accepted:
374
+ if isinstance(cand, type):
375
+ py = cand
376
+ break
377
+ schema = _py_to_json_type(py or str)
378
+
379
+ for cls in cls_names:
380
+ fmt = _FORMAT_BY_CLS.get(cls)
381
+ if fmt:
382
+ schema.setdefault("format", fmt)
383
+ break
384
+
385
+ tns, tname = self._type_qkey(t)
386
+ builtin_names = {"string", "int", "integer", "decimal", "boolean",
387
+ "float", "double", "dateTime", "date", "time",
388
+ "long", "short", "byte", "anyURI", "base64Binary"}
389
+ if tname and tname not in builtin_names:
390
+ facets = self._lookup(self.meta.facets, tns, tname)
391
+ if facets:
392
+ for k, v in facets.items():
393
+ schema.setdefault(k, v)
394
+ doc = self._lookup(self.meta.type_docs, tns, tname)
395
+ if doc:
396
+ schema.setdefault("description", doc)
397
+ schema["x-soap-simple-type"] = tname
398
+ return schema
spec2openapi/server.py ADDED
@@ -0,0 +1,85 @@
1
+ """Optional MCP runtime glue (requires the [mcp] extra: fastmcp + httpx).
2
+
3
+ This module is a *reference implementation* showing how an openapi->MCP
4
+ runtime consumes specs generated by spec2openapi: FastMCP.from_openapi plus
5
+ the SOAP bridge transport that understands the x-soap extensions.
6
+
7
+ mcp = spec2openapi.from_openapi_spec(spec2openapi.load_spec("spec.yaml"))
8
+ mcp.run(transport="http", host="0.0.0.0", port=8000)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from .convert import convert_wsdl, load_spec, spec_has_soap # noqa: F401
16
+
17
+ logger = logging.getLogger("spec2openapi")
18
+
19
+ BRIDGE_BASE_URL = "http://spec2openapi.bridge.local"
20
+
21
+ _MCP_HINT = (
22
+ "the MCP runtime requires optional dependencies; "
23
+ "install them with: pip install 'spec2openapi[mcp]'"
24
+ )
25
+
26
+
27
+ def from_openapi_spec(
28
+ spec: dict[str, Any],
29
+ *,
30
+ name: str | None = None,
31
+ options: Any | None = None,
32
+ validate_output: bool = False,
33
+ **fastmcp_kwargs: Any,
34
+ ):
35
+ """OpenAPI dict -> FastMCP server.
36
+
37
+ Specs containing x-soap operations are routed through the SOAP bridge;
38
+ plain REST specs get a regular httpx client (one runtime serves both).
39
+ """
40
+ try:
41
+ import httpx
42
+ from fastmcp import FastMCP
43
+ except ImportError as exc: # pragma: no cover
44
+ raise ImportError(_MCP_HINT) from exc
45
+
46
+ from .bridge import BridgeOptions, SoapBridgeTransport
47
+
48
+ if spec_has_soap(spec):
49
+ transport = SoapBridgeTransport(spec, options)
50
+ client = httpx.AsyncClient(transport=transport, base_url=BRIDGE_BASE_URL)
51
+ else:
52
+ options = options or BridgeOptions.from_env()
53
+ base = options.endpoint or (spec.get("servers") or [{}])[0].get("url", "")
54
+ auth = None
55
+ if options.auth == "basic" and options.username is not None:
56
+ auth = (options.username, options.password or "")
57
+ client = httpx.AsyncClient(
58
+ base_url=base, auth=auth,
59
+ timeout=options.timeout, verify=options.verify,
60
+ headers=options.headers or {},
61
+ trust_env=options.trust_env,
62
+ )
63
+
64
+ return FastMCP.from_openapi(
65
+ openapi_spec=spec,
66
+ client=client,
67
+ name=name or spec.get("info", {}).get("title", "spec2openapi"),
68
+ validate_output=validate_output,
69
+ **fastmcp_kwargs,
70
+ )
71
+
72
+
73
+ def from_wsdl(
74
+ source: str,
75
+ *,
76
+ name: str | None = None,
77
+ options: Any | None = None,
78
+ validate_output: bool = False,
79
+ **convert_kwargs: Any,
80
+ ):
81
+ """WSDL (path/URL) -> FastMCP server in one call."""
82
+ spec = convert_wsdl(source, **convert_kwargs)
83
+ return from_openapi_spec(
84
+ spec, name=name, options=options, validate_output=validate_output
85
+ )