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/parser.py ADDED
@@ -0,0 +1,403 @@
1
+ """WSDL parsing built on zeep.
2
+
3
+ Extracts SOAP operations, endpoints, headers, faults, documentation and XSD
4
+ facets (from all schema documents, including xsd:import-ed ones) into a
5
+ plain intermediate model consumed by the OpenAPI generator.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import logging
11
+ import os
12
+ from typing import Any
13
+
14
+ from lxml import etree
15
+ from zeep import Client, Settings
16
+ from zeep.wsdl.bindings.soap import Soap12Binding, SoapBinding
17
+
18
+ logger = logging.getLogger("spec2openapi")
19
+
20
+ XSD_NS = "http://www.w3.org/2001/XMLSchema"
21
+ WSDL_NS = "http://schemas.xmlsoap.org/wsdl/"
22
+
23
+
24
+ class UnsupportedWsdlError(Exception):
25
+ """Raised in strict mode when an operation cannot be converted."""
26
+
27
+
28
+ @dataclasses.dataclass
29
+ class ParsedFault:
30
+ name: str
31
+ element: Any | None # zeep Element of the fault detail, if resolvable
32
+
33
+
34
+ @dataclasses.dataclass
35
+ class ParsedHeader:
36
+ part: str
37
+ element: Any # zeep Element
38
+
39
+
40
+ @dataclasses.dataclass
41
+ class ParsedOperation:
42
+ name: str
43
+ op_id: str # unique OpenAPI operationId (may differ on collisions)
44
+ service: str
45
+ port: str
46
+ soap_action: str
47
+ soap_version: str # "1.1" | "1.2"
48
+ style: str # "document" | "rpc"
49
+ endpoint: str
50
+ documentation: str | None
51
+ input_element: Any # zeep xsd Element (wrapper)
52
+ output_element: Any | None
53
+ headers: list[ParsedHeader]
54
+ faults: list[ParsedFault]
55
+
56
+
57
+ @dataclasses.dataclass
58
+ class XsdMeta:
59
+ """Metadata zeep does not expose, scraped from the raw schema XML."""
60
+
61
+ # (namespace, simpleType name) -> JSON-Schema-ready facet dict
62
+ facets: dict[tuple[str, str], dict[str, Any]]
63
+ # (namespace, container name) -> documentation (types and global elements)
64
+ type_docs: dict[tuple[str, str], str]
65
+ # (namespace, container name, child element name) -> documentation
66
+ child_docs: dict[tuple[str, str, str], str]
67
+
68
+
69
+ @dataclasses.dataclass
70
+ class ParsedWsdl:
71
+ source: str
72
+ name: str
73
+ documentation: str | None
74
+ operations: list[ParsedOperation]
75
+ xsd_meta: XsdMeta
76
+ skipped: list[tuple[str, str]] # (operation, reason)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # raw XML scraping (facets + documentation)
81
+ # ---------------------------------------------------------------------------
82
+
83
+ # WSDL/XSD documents are untrusted input: never resolve entities, load DTDs,
84
+ # or touch the network while parsing (fetching is done explicitly upstream).
85
+ _SAFE_XML = etree.XMLParser(
86
+ resolve_entities=False, load_dtd=False, no_network=True, huge_tree=False
87
+ )
88
+
89
+
90
+ def _load_raw(location: str, *, allow_remote: bool = True) -> bytes | None:
91
+ try:
92
+ if os.path.exists(location):
93
+ with open(location, "rb") as f:
94
+ return f.read()
95
+ if allow_remote and location.startswith(("http://", "https://")):
96
+ from zeep.transports import Transport
97
+
98
+ return Transport().load(location)
99
+ except Exception as exc: # pragma: no cover - network/IO edge
100
+ logger.debug("could not fetch %s for metadata: %s", location, exc)
101
+ return None
102
+
103
+
104
+ def _doc_text(node: etree._Element) -> str | None:
105
+ doc = node.find(f"{{{XSD_NS}}}annotation/{{{XSD_NS}}}documentation")
106
+ if doc is not None and doc.text and doc.text.strip():
107
+ return " ".join(doc.text.split())
108
+ return None
109
+
110
+
111
+ _FACET_MAP = {
112
+ "pattern": ("pattern", str),
113
+ "minLength": ("minLength", int),
114
+ "maxLength": ("maxLength", int),
115
+ "minInclusive": ("minimum", float),
116
+ "maxInclusive": ("maximum", float),
117
+ "minExclusive": ("exclusiveMinimumValue", float),
118
+ "maxExclusive": ("exclusiveMaximumValue", float),
119
+ }
120
+
121
+
122
+ def _facets_from_restriction(st: etree._Element) -> dict[str, Any]:
123
+ """xsd:simpleType node -> JSON Schema facet fragment (3.0 flavored)."""
124
+ out: dict[str, Any] = {}
125
+ restriction = st.find(f"{{{XSD_NS}}}restriction")
126
+ if restriction is None:
127
+ return out
128
+ enums = [
129
+ e.get("value")
130
+ for e in restriction.findall(f"{{{XSD_NS}}}enumeration")
131
+ if e.get("value") is not None
132
+ ]
133
+ if enums:
134
+ out["enum"] = enums
135
+ for facet in restriction:
136
+ if not isinstance(facet.tag, str):
137
+ continue
138
+ local = etree.QName(facet).localname
139
+ value = facet.get("value")
140
+ if value is None:
141
+ continue
142
+ if local == "length":
143
+ try:
144
+ out["minLength"] = out["maxLength"] = int(value)
145
+ except ValueError:
146
+ pass
147
+ elif local == "fractionDigits":
148
+ try:
149
+ digits = int(value)
150
+ if digits > 0:
151
+ out["multipleOf"] = round(10 ** -digits, digits)
152
+ except ValueError:
153
+ pass
154
+ elif local in _FACET_MAP:
155
+ key, cast = _FACET_MAP[local]
156
+ try:
157
+ out[key] = cast(value)
158
+ except ValueError:
159
+ pass
160
+ # OpenAPI 3.0 exclusive bounds are boolean flags on minimum/maximum
161
+ if "exclusiveMinimumValue" in out:
162
+ out["minimum"] = out.pop("exclusiveMinimumValue")
163
+ out["exclusiveMinimum"] = True
164
+ if "exclusiveMaximumValue" in out:
165
+ out["maximum"] = out.pop("exclusiveMaximumValue")
166
+ out["exclusiveMaximum"] = True
167
+ return out
168
+
169
+
170
+ def _scan_schema_root(root: etree._Element, meta: XsdMeta) -> None:
171
+ for schema in root.iter(f"{{{XSD_NS}}}schema"):
172
+ tns = schema.get("targetNamespace", "")
173
+ # named simple types: facets + docs
174
+ for st in schema.findall(f"{{{XSD_NS}}}simpleType[@name]"):
175
+ key = (tns, st.get("name"))
176
+ facets = _facets_from_restriction(st)
177
+ doc = _doc_text(st)
178
+ if doc:
179
+ meta.type_docs.setdefault(key, doc)
180
+ if facets:
181
+ meta.facets.setdefault(key, dict(facets))
182
+ # named complex types and global elements: docs (own + children)
183
+ for container_tag in ("complexType", "element"):
184
+ for node in schema.findall(f"{{{XSD_NS}}}{container_tag}[@name]"):
185
+ cname = node.get("name")
186
+ doc = _doc_text(node)
187
+ if doc:
188
+ meta.type_docs.setdefault((tns, cname), doc)
189
+ for child in node.iter(f"{{{XSD_NS}}}element"):
190
+ if child is node or not child.get("name"):
191
+ continue
192
+ cdoc = _doc_text(child)
193
+ if cdoc:
194
+ meta.child_docs.setdefault(
195
+ (tns, cname, child.get("name")), cdoc
196
+ )
197
+
198
+
199
+ def _collect_xsd_meta(client: Client, source: str,
200
+ *, forbid_external: bool = False) -> XsdMeta:
201
+ meta = XsdMeta(facets={}, type_docs={}, child_docs={})
202
+ locations: list[str] = []
203
+ try:
204
+ for entry in client.wsdl.types.documents.values():
205
+ docs = entry if isinstance(entry, list) else [entry]
206
+ for d in docs:
207
+ loc = getattr(d, "_location", None)
208
+ if loc and loc not in locations:
209
+ locations.append(loc)
210
+ except Exception as exc: # pragma: no cover
211
+ logger.debug("schema document introspection failed: %s", exc)
212
+ if source not in locations:
213
+ locations.append(source)
214
+ for loc in locations:
215
+ # the source itself was chosen by the caller; imported locations
216
+ # are attacker-controllable and honor forbid_external
217
+ raw = _load_raw(loc, allow_remote=(loc == source or not forbid_external))
218
+ if not raw:
219
+ continue
220
+ try:
221
+ root = etree.fromstring(raw, parser=_SAFE_XML)
222
+ except Exception:
223
+ continue
224
+ _scan_schema_root(root, meta)
225
+ return meta
226
+
227
+
228
+ def _extract_wsdl_docs(source: str) -> tuple[str | None, dict[str, str]]:
229
+ raw = _load_raw(source)
230
+ if not raw:
231
+ return None, {}
232
+ try:
233
+ tree = etree.fromstring(raw, parser=_SAFE_XML)
234
+ except Exception:
235
+ return None, {}
236
+ ns = {"wsdl": WSDL_NS}
237
+ svc_doc = None
238
+ node = tree.find("wsdl:documentation", ns)
239
+ if node is not None and node.text:
240
+ svc_doc = node.text.strip()
241
+ op_docs: dict[str, str] = {}
242
+ for opel in tree.findall(".//wsdl:portType/wsdl:operation", ns):
243
+ doc = opel.find("wsdl:documentation", ns)
244
+ if doc is not None and doc.text and opel.get("name"):
245
+ op_docs[opel.get("name")] = doc.text.strip()
246
+ return svc_doc, op_docs
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # operation extraction
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def _extract_headers(op: Any) -> list[ParsedHeader]:
255
+ headers: list[ParsedHeader] = []
256
+ header = getattr(op.input, "header", None) if op.input else None
257
+ if header is None:
258
+ return headers
259
+ try:
260
+ for pname, pel in header.type.elements:
261
+ headers.append(ParsedHeader(part=pname, element=pel))
262
+ except Exception as exc:
263
+ logger.warning("could not resolve soap:header of %s: %s",
264
+ getattr(op, "name", "?"), exc)
265
+ return headers
266
+
267
+
268
+ def _extract_faults(op: Any) -> list[ParsedFault]:
269
+ faults: list[ParsedFault] = []
270
+ fault_messages = getattr(op.abstract, "fault_messages", None) or {}
271
+ for fname, fmsg in fault_messages.items():
272
+ element = None
273
+ for part in (getattr(fmsg, "parts", None) or {}).values():
274
+ element = getattr(part, "element", None)
275
+ if element is not None:
276
+ break
277
+ faults.append(ParsedFault(name=fname, element=element))
278
+ return faults
279
+
280
+
281
+ def parse_wsdl(
282
+ source: str,
283
+ *,
284
+ service: str | None = None,
285
+ port: str | None = None,
286
+ prefer_soap12: bool = False,
287
+ strict: bool = False,
288
+ forbid_external: bool = False,
289
+ huge_tree: bool = False,
290
+ ) -> ParsedWsdl:
291
+ """Parse a WSDL (path or URL) into the intermediate model.
292
+
293
+ Supports document/literal and rpc/literal SOAP bindings; operations that
294
+ cannot be represented are skipped (or raise in strict mode).
295
+
296
+ forbid_external=True refuses to fetch remote wsdl:/xsd: imports —
297
+ recommended when converting documents from untrusted sources (local
298
+ relative imports still work). huge_tree=True lifts libxml2 depth/size
299
+ limits for very large WSDLs; leave off for untrusted input.
300
+ """
301
+ settings = Settings(
302
+ strict=False, xml_huge_tree=huge_tree,
303
+ forbid_dtd=True, forbid_entities=True,
304
+ forbid_external=forbid_external,
305
+ )
306
+ client = Client(source, settings=settings)
307
+
308
+ svc_doc, op_docs = _extract_wsdl_docs(source)
309
+ xsd_meta = _collect_xsd_meta(client, source, forbid_external=forbid_external)
310
+
311
+ operations: list[ParsedOperation] = []
312
+ skipped: list[tuple[str, str]] = []
313
+ seen: dict[str, Any] = {} # op name -> input element qname
314
+ used_ids: set[str] = set()
315
+ first_service_name = None
316
+
317
+ def sort_key(item):
318
+ _, p = item
319
+ is12 = isinstance(p.binding, Soap12Binding)
320
+ return (not is12) if prefer_soap12 else is12
321
+
322
+ for sname, svc in client.wsdl.services.items():
323
+ if service and sname != service:
324
+ continue
325
+ if first_service_name is None:
326
+ first_service_name = sname
327
+ for pname, prt in sorted(svc.ports.items(), key=sort_key):
328
+ if port and pname != port:
329
+ continue
330
+ binding = prt.binding
331
+ if not isinstance(binding, SoapBinding):
332
+ skipped.append((f"{sname}/{pname}", "non-SOAP binding"))
333
+ continue
334
+ soap_version = "1.2" if isinstance(binding, Soap12Binding) else "1.1"
335
+ endpoint = prt.binding_options.get("address", "")
336
+ for opname, op in binding._operations.items():
337
+ style = getattr(op, "style", "document")
338
+ body = getattr(op.input, "body", None) if op.input else None
339
+ if body is None or not hasattr(getattr(body, "type", None), "elements"):
340
+ reason = (
341
+ f"style '{style}': input message has no resolvable body "
342
+ "element (rpc/encoded is not supported)"
343
+ )
344
+ if strict:
345
+ raise UnsupportedWsdlError(f"{opname}: {reason}")
346
+ skipped.append((opname, reason))
347
+ continue
348
+
349
+ in_qname = getattr(body, "qname", None)
350
+ if opname in seen:
351
+ if seen[opname] == in_qname:
352
+ continue # same operation exposed via another port
353
+ op_id = f"{sname}_{opname}"
354
+ else:
355
+ op_id = opname
356
+ base_id = op_id
357
+ n = 2
358
+ while op_id in used_ids:
359
+ op_id = f"{base_id}_{n}"
360
+ n += 1
361
+
362
+ out_body = None
363
+ if op.output is not None:
364
+ out_body = getattr(op.output, "body", None)
365
+ if out_body is not None and not hasattr(
366
+ getattr(out_body, "type", None), "elements"
367
+ ):
368
+ out_body = None
369
+
370
+ operations.append(
371
+ ParsedOperation(
372
+ name=opname,
373
+ op_id=op_id,
374
+ service=sname,
375
+ port=pname,
376
+ soap_action=op.soapaction or "",
377
+ soap_version=soap_version,
378
+ style=style,
379
+ endpoint=endpoint,
380
+ documentation=op_docs.get(opname),
381
+ input_element=body,
382
+ output_element=out_body,
383
+ headers=_extract_headers(op),
384
+ faults=_extract_faults(op),
385
+ )
386
+ )
387
+ seen[opname] = in_qname
388
+ used_ids.add(op_id)
389
+
390
+ if not operations and strict:
391
+ raise UnsupportedWsdlError("no convertible SOAP operations found")
392
+
393
+ for opname, reason in skipped:
394
+ logger.warning("skipped %s: %s", opname, reason)
395
+
396
+ return ParsedWsdl(
397
+ source=source,
398
+ name=first_service_name or "SoapService",
399
+ documentation=svc_doc,
400
+ operations=operations,
401
+ xsd_meta=xsd_meta,
402
+ skipped=skipped,
403
+ )