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/swagger.py
ADDED
|
@@ -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
|
+
[](https://github.com/Seo-yul/spec2openapi/actions/workflows/ci.yml)
|
|
47
|
+
[](https://pypi.org/project/spec2openapi/)
|
|
48
|
+
[](pyproject.toml)
|
|
49
|
+
[](LICENSE)
|
|
50
|
+
[](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,,
|