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,430 @@
1
+ """Swagger 2.0 -> OpenAPI 3.x upgrade (FastMCP only accepts OpenAPI 3.x).
2
+
3
+ Design principles for information gaps (2.0 documents often omit things):
4
+
5
+ 1. Apply industry-consensus defaults deterministically and RECORD every
6
+ assumption under the root `x-s2o.assumptions` so the upgrade is
7
+ auditable (missing consumes/produces -> application/json, missing
8
+ operationId -> "{method}_{path}", missing host -> relative server "/").
9
+ 2. Never silently drop untranslatable constructs: preserve them as `x-`
10
+ extensions and record them under `x-s2o.lossy`.
11
+ 3. The final gate is `spec2openapi validate`, whose FastMCP round-trip
12
+ proves the upgraded spec actually materializes as MCP tools.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Any
18
+
19
+ from . import __version__ as _version
20
+ from .openapi import to_openapi_31
21
+
22
+ _METHODS = ("get", "put", "post", "delete", "options", "head", "patch")
23
+
24
+ # Swagger 2.0 parameter fields that move into `schema` in OpenAPI 3
25
+ _SCHEMA_FIELDS = (
26
+ "type", "format", "items", "default", "maximum", "exclusiveMaximum",
27
+ "minimum", "exclusiveMinimum", "maxLength", "minLength", "pattern",
28
+ "maxItems", "minItems", "uniqueItems", "enum", "multipleOf",
29
+ )
30
+
31
+ # FastMCP normalizes tool names to [A-Za-z0-9_]; generate ids accordingly
32
+ # so that tool name == operationId holds after the round-trip.
33
+ _ID_RE = re.compile(r"[^A-Za-z0-9_]+")
34
+
35
+
36
+ def is_swagger2(spec: dict[str, Any]) -> bool:
37
+ return str(spec.get("swagger", "")).startswith("2")
38
+
39
+
40
+ def convert_swagger(
41
+ spec: dict[str, Any], *, openapi_version: str = "3.0"
42
+ ) -> dict[str, Any]:
43
+ """Upgrade a Swagger 2.0 dict to OpenAPI 3.0 (or 3.1)."""
44
+ if not is_swagger2(spec):
45
+ raise ValueError("not a Swagger 2.0 document (missing swagger: '2.0')")
46
+ up = _Upgrader(spec)
47
+ out = up.convert()
48
+ if openapi_version.startswith("3.1"):
49
+ out = to_openapi_31(out)
50
+ return out
51
+
52
+
53
+ class _Upgrader:
54
+ def __init__(self, src: dict[str, Any]):
55
+ self.src = src
56
+ self.assumptions: list[str] = []
57
+ self.lossy: list[str] = []
58
+ # global body-parameters become requestBodies, not parameters
59
+ self._global_body_params: set[str] = {
60
+ name
61
+ for name, p in (src.get("parameters") or {}).items()
62
+ if isinstance(p, dict) and p.get("in") == "body"
63
+ }
64
+
65
+ # -- helpers -----------------------------------------------------------
66
+
67
+ def _fix_ref(self, ref: str) -> str:
68
+ if ref.startswith("#/definitions/"):
69
+ return ref.replace("#/definitions/", "#/components/schemas/", 1)
70
+ if ref.startswith("#/parameters/"):
71
+ name = ref.rsplit("/", 1)[-1]
72
+ if name in self._global_body_params:
73
+ return f"#/components/requestBodies/{name}"
74
+ return ref.replace("#/parameters/", "#/components/parameters/", 1)
75
+ if ref.startswith("#/responses/"):
76
+ return ref.replace("#/responses/", "#/components/responses/", 1)
77
+ return ref
78
+
79
+ def _fix_schema(self, node: Any) -> Any:
80
+ """Recursive schema fixups: $refs, type:file, x-nullable,
81
+ discriminator string -> object."""
82
+ if isinstance(node, list):
83
+ return [self._fix_schema(v) for v in node]
84
+ if not isinstance(node, dict):
85
+ return node
86
+ out: dict[str, Any] = {}
87
+ for k, v in node.items():
88
+ if k == "$ref" and isinstance(v, str):
89
+ out[k] = self._fix_ref(v)
90
+ elif k == "x-nullable":
91
+ out["nullable"] = v
92
+ elif k == "discriminator" and isinstance(v, str):
93
+ out[k] = {"propertyName": v}
94
+ else:
95
+ out[k] = self._fix_schema(v)
96
+ if out.get("type") == "file":
97
+ out["type"] = "string"
98
+ out["format"] = "binary"
99
+ return out
100
+
101
+ def _media_types(self, kind: str, op: dict, ctx: str) -> list[str]:
102
+ types = op.get(kind) or self.src.get(kind) or []
103
+ if not types:
104
+ self.assumptions.append(
105
+ f"{ctx}: no '{kind}' declared; assumed application/json"
106
+ )
107
+ return ["application/json"]
108
+ return list(types)
109
+
110
+ # -- parameters --------------------------------------------------------
111
+
112
+ def _convert_param(self, p: dict, ctx: str) -> dict:
113
+ """query/path/header (non-body, non-formData) parameter."""
114
+ out = {
115
+ k: v
116
+ for k, v in p.items()
117
+ if k in ("name", "in", "description", "required", "allowEmptyValue")
118
+ or k.startswith("x-")
119
+ }
120
+ schema = {k: self._fix_schema(v) for k, v in p.items()
121
+ if k in _SCHEMA_FIELDS}
122
+ if schema.get("type") == "file":
123
+ schema = {"type": "string", "format": "binary"}
124
+ out["schema"] = schema or {"type": "string"}
125
+
126
+ cf = p.get("collectionFormat")
127
+ if cf:
128
+ loc = p.get("in")
129
+ if cf == "csv":
130
+ out["style"] = "form" if loc in ("query", "formData") else "simple"
131
+ out["explode"] = False
132
+ elif cf == "multi":
133
+ out["style"] = "form"
134
+ out["explode"] = True
135
+ elif cf == "ssv":
136
+ out["style"] = "spaceDelimited"
137
+ out["explode"] = False
138
+ elif cf == "pipes":
139
+ out["style"] = "pipeDelimited"
140
+ out["explode"] = False
141
+ else: # tsv and friends have no OpenAPI 3 equivalent
142
+ out["x-collectionFormat"] = cf
143
+ self.lossy.append(
144
+ f"{ctx}: collectionFormat '{cf}' has no OpenAPI 3 "
145
+ "equivalent; preserved as x-collectionFormat"
146
+ )
147
+ return out
148
+
149
+ def _body_to_request_body(self, p: dict, op: dict, ctx: str) -> dict:
150
+ rb: dict[str, Any] = {}
151
+ if p.get("description"):
152
+ rb["description"] = p["description"]
153
+ if p.get("required"):
154
+ rb["required"] = True
155
+ if p.get("name"):
156
+ rb["x-original-body-name"] = p["name"]
157
+ schema = self._fix_schema(p.get("schema", {}))
158
+ rb["content"] = {
159
+ mt: {"schema": schema}
160
+ for mt in self._media_types("consumes", op, ctx)
161
+ }
162
+ return rb
163
+
164
+ def _form_to_request_body(self, params: list[dict], op: dict,
165
+ ctx: str) -> dict:
166
+ has_file = any(p.get("type") == "file" for p in params)
167
+ media = "multipart/form-data" if has_file else (
168
+ "application/x-www-form-urlencoded"
169
+ )
170
+ props: dict[str, Any] = {}
171
+ required: list[str] = []
172
+ for p in params:
173
+ schema = {k: self._fix_schema(v) for k, v in p.items()
174
+ if k in _SCHEMA_FIELDS}
175
+ if p.get("type") == "file":
176
+ schema = {"type": "string", "format": "binary"}
177
+ if p.get("description"):
178
+ schema["description"] = p["description"]
179
+ props[p["name"]] = schema or {"type": "string"}
180
+ if p.get("required"):
181
+ required.append(p["name"])
182
+ body_schema: dict[str, Any] = {"type": "object", "properties": props}
183
+ if required:
184
+ body_schema["required"] = required
185
+ return {
186
+ "required": bool(required),
187
+ "content": {media: {"schema": body_schema}},
188
+ }
189
+
190
+ def _split_params(self, raw: list, op: dict, ctx: str
191
+ ) -> tuple[list, dict | None]:
192
+ """-> (parameters, requestBody|None)"""
193
+ params: list[dict] = []
194
+ body: dict | None = None
195
+ form: list[dict] = []
196
+ for p in raw:
197
+ if not isinstance(p, dict):
198
+ continue
199
+ if "$ref" in p:
200
+ ref = self._fix_ref(p["$ref"])
201
+ if "/requestBodies/" in ref:
202
+ body = {"$ref": ref}
203
+ else:
204
+ params.append({"$ref": ref})
205
+ continue
206
+ loc = p.get("in")
207
+ if loc == "body":
208
+ body = self._body_to_request_body(p, op, ctx)
209
+ elif loc == "formData":
210
+ form.append(p)
211
+ else:
212
+ params.append(self._convert_param(p, ctx))
213
+ if form:
214
+ if body is not None:
215
+ self.lossy.append(
216
+ f"{ctx}: both body and formData parameters present; "
217
+ "formData ignored"
218
+ )
219
+ else:
220
+ body = self._form_to_request_body(form, op, ctx)
221
+ return params, body
222
+
223
+ # -- responses ----------------------------------------------------------
224
+
225
+ def _convert_response(self, resp: dict, op: dict, ctx: str) -> dict:
226
+ if "$ref" in resp:
227
+ return {"$ref": self._fix_ref(resp["$ref"])}
228
+ out: dict[str, Any] = {
229
+ "description": resp.get("description", "")
230
+ }
231
+ for k, v in resp.items():
232
+ if k.startswith("x-"):
233
+ out[k] = v
234
+ if "schema" in resp:
235
+ schema = self._fix_schema(resp["schema"])
236
+ examples = resp.get("examples") or {}
237
+ content: dict[str, Any] = {}
238
+ for mt in self._media_types("produces", op, ctx):
239
+ entry: dict[str, Any] = {"schema": schema}
240
+ if mt in examples:
241
+ entry["example"] = examples[mt]
242
+ content[mt] = entry
243
+ out["content"] = content
244
+ if "headers" in resp:
245
+ headers = {}
246
+ for hname, h in resp["headers"].items():
247
+ hh = {k: v for k, v in h.items() if k == "description"}
248
+ hh["schema"] = {
249
+ k: self._fix_schema(v) for k, v in h.items()
250
+ if k in _SCHEMA_FIELDS
251
+ } or {"type": "string"}
252
+ headers[hname] = hh
253
+ out["headers"] = headers
254
+ return out
255
+
256
+ # -- security -------------------------------------------------------------
257
+
258
+ def _convert_security_schemes(self) -> dict:
259
+ out: dict[str, Any] = {}
260
+ flow_names = {
261
+ "implicit": "implicit",
262
+ "password": "password",
263
+ "application": "clientCredentials",
264
+ "accessCode": "authorizationCode",
265
+ }
266
+ for name, sd in (self.src.get("securityDefinitions") or {}).items():
267
+ t = sd.get("type")
268
+ if t == "basic":
269
+ out[name] = {"type": "http", "scheme": "basic"}
270
+ if sd.get("description"):
271
+ out[name]["description"] = sd["description"]
272
+ elif t == "apiKey":
273
+ out[name] = {k: sd[k] for k in ("type", "name", "in")
274
+ if k in sd}
275
+ if sd.get("description"):
276
+ out[name]["description"] = sd["description"]
277
+ elif t == "oauth2":
278
+ flow = flow_names.get(sd.get("flow"), sd.get("flow"))
279
+ flow_obj: dict[str, Any] = {
280
+ "scopes": sd.get("scopes", {}),
281
+ }
282
+ if "authorizationUrl" in sd:
283
+ flow_obj["authorizationUrl"] = sd["authorizationUrl"]
284
+ if "tokenUrl" in sd:
285
+ flow_obj["tokenUrl"] = sd["tokenUrl"]
286
+ entry: dict[str, Any] = {
287
+ "type": "oauth2", "flows": {flow: flow_obj},
288
+ }
289
+ if sd.get("description"):
290
+ entry["description"] = sd["description"]
291
+ out[name] = entry
292
+ else:
293
+ out[name] = dict(sd)
294
+ self.lossy.append(
295
+ f"securityDefinitions.{name}: unknown type '{t}' copied as-is"
296
+ )
297
+ return out
298
+
299
+ # -- top level -------------------------------------------------------------
300
+
301
+ def _servers(self) -> list[dict]:
302
+ host = self.src.get("host")
303
+ base = self.src.get("basePath", "")
304
+ schemes = self.src.get("schemes")
305
+ if not host:
306
+ self.assumptions.append(
307
+ "no 'host' declared; emitted relative server url "
308
+ f"'{base or '/'}' (override endpoint at runtime)"
309
+ )
310
+ return [{"url": base or "/"}]
311
+ if not schemes:
312
+ schemes = ["https"]
313
+ self.assumptions.append(
314
+ "no 'schemes' declared; assumed https"
315
+ )
316
+ return [{"url": f"{s}://{host}{base}"} for s in schemes]
317
+
318
+ def _gen_operation_id(self, method: str, path: str) -> str:
319
+ raw = f"{method}_{path.strip('/') or 'root'}"
320
+ raw = raw.replace("{", "").replace("}", "")
321
+ return _ID_RE.sub("_", raw).strip("_")[:64]
322
+
323
+ def convert(self) -> dict[str, Any]:
324
+ src = self.src
325
+ out: dict[str, Any] = {
326
+ "openapi": "3.0.3",
327
+ "info": dict(src.get("info", {"title": "API", "version": "0.0.0"})),
328
+ "servers": self._servers(),
329
+ "paths": {},
330
+ }
331
+ for k in ("tags", "externalDocs", "security"):
332
+ if k in src:
333
+ out[k] = src[k]
334
+
335
+ used_ids: set[str] = set()
336
+ for path, item in (src.get("paths") or {}).items():
337
+ if not isinstance(item, dict):
338
+ continue
339
+ new_item: dict[str, Any] = {}
340
+ shared_raw = item.get("parameters", [])
341
+ for k, v in item.items():
342
+ if k.startswith("x-"):
343
+ new_item[k] = v
344
+ for method in _METHODS:
345
+ op = item.get(method)
346
+ if not isinstance(op, dict):
347
+ continue
348
+ ctx = f"{method.upper()} {path}"
349
+ new_op: dict[str, Any] = {}
350
+ for k in ("summary", "description", "tags", "deprecated",
351
+ "externalDocs", "security"):
352
+ if k in op:
353
+ new_op[k] = op[k]
354
+ for k, v in op.items():
355
+ if k.startswith("x-"):
356
+ new_op[k] = v
357
+
358
+ op_id = op.get("operationId")
359
+ if not op_id:
360
+ op_id = self._gen_operation_id(method, path)
361
+ self.assumptions.append(
362
+ f"{ctx}: no operationId; generated '{op_id}'"
363
+ )
364
+ else:
365
+ normalized = _ID_RE.sub("_", op_id).strip("_")[:64]
366
+ if normalized != op_id:
367
+ self.assumptions.append(
368
+ f"{ctx}: operationId '{op_id}' normalized to "
369
+ f"'{normalized}' (FastMCP tool-name convention)"
370
+ )
371
+ op_id = normalized
372
+ base_id = op_id
373
+ n = 2
374
+ while op_id in used_ids:
375
+ op_id = f"{base_id}_{n}"
376
+ n += 1
377
+ used_ids.add(op_id)
378
+ new_op["operationId"] = op_id
379
+
380
+ raw_params = list(shared_raw) + list(op.get("parameters", []))
381
+ params, request_body = self._split_params(raw_params, op, ctx)
382
+ if params:
383
+ new_op["parameters"] = params
384
+ if request_body is not None:
385
+ new_op["requestBody"] = request_body
386
+
387
+ new_op["responses"] = {
388
+ str(code): self._convert_response(resp, op, ctx)
389
+ for code, resp in (op.get("responses") or {}).items()
390
+ } or {"200": {"description": "OK"}}
391
+
392
+ new_item[method] = new_op
393
+ out["paths"][path] = new_item
394
+
395
+ components: dict[str, Any] = {}
396
+ if src.get("definitions"):
397
+ components["schemas"] = {
398
+ name: self._fix_schema(schema)
399
+ for name, schema in src["definitions"].items()
400
+ }
401
+ global_params = src.get("parameters") or {}
402
+ conv_params = {}
403
+ request_bodies = {}
404
+ for name, p in global_params.items():
405
+ if name in self._global_body_params:
406
+ request_bodies[name] = self._body_to_request_body(p, {}, name)
407
+ else:
408
+ conv_params[name] = self._convert_param(p, f"parameters.{name}")
409
+ if conv_params:
410
+ components["parameters"] = conv_params
411
+ if request_bodies:
412
+ components["requestBodies"] = request_bodies
413
+ if src.get("responses"):
414
+ components["responses"] = {
415
+ name: self._convert_response(r, {}, f"responses.{name}")
416
+ for name, r in src["responses"].items()
417
+ }
418
+ schemes = self._convert_security_schemes()
419
+ if schemes:
420
+ components["securitySchemes"] = schemes
421
+ if components:
422
+ out["components"] = components
423
+
424
+ out["x-s2o"] = {
425
+ "source": "swagger-2.0",
426
+ "generator": f"spec2openapi/{_version}",
427
+ "assumptions": self.assumptions,
428
+ "lossy": self.lossy,
429
+ }
430
+ return out
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: spec2openapi
3
+ Version: 0.1.0
4
+ Summary: Convert legacy API specs (SOAP/WSDL, Swagger 2.0) into FastMCP-ready OpenAPI 3.x documents
5
+ Author-email: Seoyul Yoon <devops.reso@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Seo-yul/spec2openapi
8
+ Project-URL: Repository, https://github.com/Seo-yul/spec2openapi
9
+ Project-URL: Issues, https://github.com/Seo-yul/spec2openapi/issues
10
+ Project-URL: Changelog, https://github.com/Seo-yul/spec2openapi/blob/main/CHANGELOG.md
11
+ Keywords: wsdl,soap,swagger,openapi,mcp,fastmcp,converter,legacy,api
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Code Generators
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ License-File: NOTICE
28
+ Requires-Dist: zeep>=4.2
29
+ Requires-Dist: lxml>=4.9
30
+ Requires-Dist: PyYAML>=6.0
31
+ Provides-Extra: mcp
32
+ Requires-Dist: fastmcp>=3.0; extra == "mcp"
33
+ Requires-Dist: httpx>=0.27; extra == "mcp"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8; extra == "dev"
36
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
37
+ Requires-Dist: fastmcp>=3.0; extra == "dev"
38
+ Requires-Dist: httpx>=0.27; extra == "dev"
39
+ Requires-Dist: openapi-spec-validator>=0.7; extra == "dev"
40
+ Dynamic: license-file
41
+
42
+ # spec2openapi
43
+
44
+ > Convert legacy API specifications — SOAP/WSDL and Swagger 2.0 — into **FastMCP-ready OpenAPI 3.x** documents.
45
+
46
+ [![CI](https://github.com/Seo-yul/spec2openapi/actions/workflows/ci.yml/badge.svg)](https://github.com/Seo-yul/spec2openapi/actions/workflows/ci.yml)
47
+ [![PyPI](https://img.shields.io/pypi/v/spec2openapi)](https://pypi.org/project/spec2openapi/)
48
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
49
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE)
50
+ [![Code of Conduct](https://img.shields.io/badge/code%20of%20conduct-Contributor%20Covenant-purple.svg)](CODE_OF_CONDUCT.md)
51
+
52
+ [한국어 문서 (Korean README)](README.ko.md)
53
+
54
+ ---
55
+
56
+ MCP (Model Context Protocol) tooling such as [FastMCP](https://gofastmcp.com) can turn an OpenAPI 3.x document into an MCP server automatically — but enterprises are full of services described only by WSDL or Swagger 2.0. `spec2openapi` closes that gap:
57
+
58
+ ```
59
+ WSDL ─────────┐
60
+ ├──(spec2openapi)──> OpenAPI 3.x (+ x-soap extensions) ──> FastMCP.from_openapi() ──> MCP tools
61
+ Swagger 2.0 ──┘
62
+ ```
63
+
64
+ The output is a single, ordinary OpenAPI document. It is designed for a **fixed-runtime deployment model**: build one FastMCP-based container image, then swap the spec (e.g. a Kubernetes ConfigMap) to mass-produce MCP servers without rebuilding anything.
65
+
66
+ ## Features
67
+
68
+ - **WSDL → OpenAPI 3.0/3.1** — document/literal and rpc/literal bindings, SOAP 1.1/1.2, nested complex types, arrays, attributes, `nillable`, inheritance (flattened `complexContent` extensions), `simpleContent` (text value + attributes), `choice` (members become optional + `x-soap-choice`), default values, recursive types, multi-service/multi-port WSDLs with automatic dedup.
69
+ - **XSD facets & docs carried into tool schemas** — enumerations, `pattern`, length and numeric bounds, `fractionDigits` (→ `multipleOf`), and `xsd:annotation` documentation are extracted (including from `xsd:import`-ed schemas) so LLMs see well-described, well-constrained tool arguments.
70
+ - **`x-soap` contract** — SOAPAction, SOAP version, endpoint, wrapper element QNames, `soap:header` parts and declared faults are embedded as vendor extensions; OpenAPI `xml` annotations carry everything a call layer needs to serialize JSON ↔ literal XML.
71
+ - **Swagger 2.0 → OpenAPI 3.x upgrade** — full mechanical mapping (servers, requestBody, formData/multipart, parameter schema wrapping, `collectionFormat` → `style`/`explode`, `$ref` rewriting, security schemes, `type: file`, `x-nullable`, discriminator). Every assumption made for missing information is recorded in `x-s2o.assumptions`; untranslatable constructs are preserved as `x-` extensions and listed in `x-s2o.lossy`.
72
+ - **FastMCP compatibility, guaranteed and verifiable** — operationIds are generated in FastMCP's tool-name alphabet (`[A-Za-z0-9_]`, unique, ≤64 chars) so *tool name == operationId*. `spec2openapi validate` proves it: static checks, `openapi-spec-validator`, and a real `FastMCP.from_openapi()` round-trip listing the resulting tools.
73
+ - **Reference MCP runtime (optional)** — `pip install "spec2openapi[mcp]"` adds a verified SOAP bridge (custom httpx transport) + FastMCP glue, a fixed Dockerfile, and Kubernetes examples. SOAP faults map to MCP tool errors; plain REST specs are served by the same runtime.
74
+
75
+ ## Installation
76
+
77
+ ```bash
78
+ pip install spec2openapi # converter only (zeep, lxml, PyYAML)
79
+ pip install "spec2openapi[mcp]" # + reference MCP runtime (fastmcp, httpx)
80
+ ```
81
+
82
+ ## Quick start
83
+
84
+ ### CLI
85
+
86
+ ```bash
87
+ # See what a WSDL contains (operations, headers, faults, style)
88
+ spec2openapi inspect https://legacy-host/OrderService?wsdl
89
+
90
+ # WSDL -> OpenAPI
91
+ spec2openapi convert https://legacy-host/OrderService?wsdl -o orders.openapi.yaml
92
+
93
+ # Swagger 2.0 -> OpenAPI 3.x (assumptions reported on stderr)
94
+ spec2openapi upgrade swagger2.json -o service.openapi.yaml
95
+
96
+ # Prove the spec converts cleanly into MCP tools
97
+ spec2openapi validate orders.openapi.yaml
98
+
99
+ # Reference MCP runtime (requires the [mcp] extra)
100
+ spec2openapi serve orders.openapi.yaml --transport http --port 8000
101
+ ```
102
+
103
+ ```text
104
+ $ spec2openapi validate orders.openapi.yaml
105
+ operations : 2
106
+ component schemas : 3
107
+ openapi-spec-validator: OK
108
+ FastMCP round-trip: OK (2 tools)
109
+ - CreateOrder(customer, items, note)
110
+ - GetOrder(orderId)
111
+
112
+ OK: spec is FastMCP-convertible
113
+ ```
114
+
115
+ ### Library
116
+
117
+ ```python
118
+ import spec2openapi
119
+
120
+ # WSDL -> OpenAPI dict
121
+ spec = spec2openapi.convert_wsdl("https://legacy-host/OrderService?wsdl")
122
+
123
+ # Swagger 2.0 -> OpenAPI dict
124
+ legacy = spec2openapi.load_spec("swagger2.json")
125
+ spec = spec2openapi.convert_swagger(legacy, openapi_version="3.1")
126
+
127
+ print(spec2openapi.dump_spec(spec)) # YAML text
128
+
129
+ # Optional [mcp] extra: run it as an MCP server right away
130
+ mcp = spec2openapi.from_openapi_spec(spec)
131
+ mcp.run(transport="http", host="0.0.0.0", port=8000)
132
+ ```
133
+
134
+ ## How SOAP calls work (the `x-soap` contract)
135
+
136
+ The generated paths (`/operations/...`) are *not* real REST endpoints — a SOAP translation layer must build the actual call. Everything it needs ships inside the spec:
137
+
138
+ | Field (`paths.*.post.x-soap`) | Meaning |
139
+ |---|---|
140
+ | `operation` / `service` / `port` | WSDL names |
141
+ | `soapAction`, `soapVersion`, `style` | `"1.1"`/`"1.2"`, `document`/`rpc` |
142
+ | `endpoint` | `soap:address` (override at runtime) |
143
+ | `input` / `output` | wrapper element QNames |
144
+ | `headers[]` | `soap:header` parts with schema refs |
145
+ | `faults[]` | declared faults with schema refs |
146
+
147
+ Serialization rules (schema `xml` annotations): `xml.name`/`xml.namespace` (absent namespace = unqualified), `xml.attribute: true`, `xml.x-text: true` (simpleContent text), arrays repeat the element, and **property order = XSD sequence order** (do not alphabetize the document). `x-soap-choice` lists mutually exclusive property groups.
148
+
149
+ The `[mcp]` extra contains a verified implementation of this contract (`src/spec2openapi/bridge.py`) — use it directly or as the reference for your own runtime.
150
+
151
+ ## Handling missing information (Swagger 2.0)
152
+
153
+ Upgrading is favorable: OpenAPI 3.x is a superset of Swagger 2.0, so almost nothing must be invented. Where documents are genuinely underspecified, a three-tier policy applies:
154
+
155
+ 1. **Deterministic, documented defaults** — missing `consumes`/`produces` → `application/json`; missing `operationId` → `{method}_{path}`; missing `host` → relative server `/`; missing `schemes` → `https`. All recorded in `x-s2o.assumptions`.
156
+ 2. **Preserve, never drop** — constructs with no OpenAPI 3 equivalent (e.g. `collectionFormat: tsv`) are kept as `x-` extensions and listed in `x-s2o.lossy`.
157
+ 3. **Verify the outcome** — `spec2openapi validate` runs the actual FastMCP round-trip; assumptions never block tool generation because tools only need paths and schemas.
158
+
159
+ ## Kubernetes: one image, many MCP servers
160
+
161
+ ```bash
162
+ docker build -t spec2openapi:0.1.0 .
163
+ spec2openapi convert <wsdl> -o openapi.yaml
164
+ kubectl create configmap my-mcp-spec --from-file=openapi.yaml
165
+ kubectl apply -f k8s/example.yaml # Deployment mounts /config/openapi.yaml
166
+ ```
167
+
168
+ Only the ConfigMap changes per service; credentials live in a Secret (`SPEC2OPENAPI_ENDPOINT`, `SPEC2OPENAPI_AUTH` = `basic`|`wsse`, `SPEC2OPENAPI_USERNAME`/`PASSWORD`, `SPEC2OPENAPI_TIMEOUT`, `SPEC2OPENAPI_VERIFY`, `SPEC2OPENAPI_TRUST_ENV`). The MCP endpoint is `http://<service>:8000/mcp` (streamable HTTP).
169
+
170
+ ## Limitations
171
+
172
+ rpc/encoded (skipped and recorded in `x-soap.skippedOperations`), MTOM/attachments, WS-Policy/WS-Addressing, and substitution groups are not supported. WS-Security support in the reference runtime is UsernameToken (PasswordText).
173
+
174
+ ## Security
175
+
176
+ All XML parsing disables DTD loading, entity resolution, and parser-level network access. When converting WSDLs from **untrusted sources**, add `--forbid-external` (CLI) or `forbid_external=True` (API) to refuse fetching remote `wsdl:`/`xsd:` imports (SSRF mitigation; local relative imports still work). See [SECURITY.md](SECURITY.md) for the full notes and how to report vulnerabilities.
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ git clone https://github.com/Seo-yul/spec2openapi.git
182
+ cd spec2openapi
183
+ pip install -e ".[dev]"
184
+ python -m pytest tests/
185
+ ```
186
+
187
+ The suite (70 tests) covers conversion units, the Swagger upgrader, envelope (de)serialization, end-to-end MCP-tool-call → mock-SOAP-server round-trips (rpc, simpleContent, choice, recursive trees, unqualified forms), FastMCP round-trips for every fixture × OpenAPI 3.0/3.1, and stress patterns (circular `$ref`s, deep nesting, large enums, cross-namespace name collisions, duplicate operation names across services, odd path characters, deep `allOf` chains). Generated samples live in [`examples/`](examples/).
188
+
189
+ ## Project layout
190
+
191
+ ```
192
+ src/spec2openapi/
193
+ parser.py WSDL parsing (zeep) + raw XSD scraping (facets/docs)
194
+ schema.py XSD -> JSON Schema (xml annotations, choice, simpleContent)
195
+ openapi.py OpenAPI 3.0/3.1 assembly + x-soap extensions
196
+ swagger.py Swagger 2.0 -> OpenAPI 3.x upgrader (x-s2o report)
197
+ convert.py core public API
198
+ cli.py convert / upgrade / inspect / validate / serve
199
+ bridge.py [mcp] SOAP bridge (httpx transport)
200
+ server.py [mcp] FastMCP glue
201
+ ```
202
+
203
+ ## Contributing
204
+
205
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md); by participating you agree to uphold it. Security issues should be reported privately per [SECURITY.md](SECURITY.md).
206
+
207
+ ## License
208
+
209
+ [Apache-2.0](LICENSE) © Seoyul Yoon
@@ -0,0 +1,16 @@
1
+ spec2openapi/__init__.py,sha256=DoFVZfZGQ5zyPwizWASea6i4Ayw7Wcu9-P8lNUu5Jis,1602
2
+ spec2openapi/bridge.py,sha256=dmFkkSVTMq61_Zc2fUfj68l351puN9yIhqbb2UFB-g8,17842
3
+ spec2openapi/cli.py,sha256=5el4EsmEIeyHisv2iC6372VQzfwsy5MsKgk9qL9iNL8,12890
4
+ spec2openapi/convert.py,sha256=-dh1enyn4nzRmMEXIu-G3tvQwfVYyiFSJT8uVOsgpqo,1860
5
+ spec2openapi/openapi.py,sha256=WaB2VOAa0ocCrFtOT6syZ1RfDmzkojm9PTo_oeLvvHE,8202
6
+ spec2openapi/parser.py,sha256=J5Dz6VzEnsO53q8A-dCXAfxq0DWHo2PUlRMoVBOOjuk,14422
7
+ spec2openapi/schema.py,sha256=tEL1E19ifBt3agQc66fUWfA-spTcPMJ8_7xk2N5ErJU,14223
8
+ spec2openapi/server.py,sha256=zm9eaYZdS55pjpWki40Gxxwu0qD-3NEwAQ9JODxHWlk,2715
9
+ spec2openapi/swagger.py,sha256=bY-no2YnHzhfAquaFDx9yWFalG0t_MyZPJoNjafzwHc,16999
10
+ spec2openapi-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
11
+ spec2openapi-0.1.0.dist-info/licenses/NOTICE,sha256=zAkiLCa3_3uH4MsMEbZl9rNVduuctejTEPG0KPk-tNw,40
12
+ spec2openapi-0.1.0.dist-info/METADATA,sha256=gA5fQwZ1f2F5jKv7Fz10VPB-wTx_BNCcRuEaONGLvHc,11408
13
+ spec2openapi-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ spec2openapi-0.1.0.dist-info/entry_points.txt,sha256=NhuUOMb8fm5PiltKu4aJF-Xl70HM8kWBLlAEvcEyWSs,55
15
+ spec2openapi-0.1.0.dist-info/top_level.txt,sha256=CftEOgnXYLVL0K29581AP6wegrffm2aaoTVBHGCSUYc,13
16
+ spec2openapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spec2openapi = spec2openapi.cli:main