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/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
|
+
)
|