spec2openapi 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0/src/spec2openapi.egg-info → spec2openapi-0.2.0}/PKG-INFO +19 -8
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/README.md +18 -7
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/pyproject.toml +1 -1
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/__init__.py +1 -1
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/bridge.py +100 -14
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/cli.py +39 -14
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/convert.py +12 -5
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/openapi.py +51 -10
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/parser.py +6 -1
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/schema.py +47 -21
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/server.py +20 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi/swagger.py +69 -10
- {spec2openapi-0.1.0 → spec2openapi-0.2.0/src/spec2openapi.egg-info}/PKG-INFO +19 -8
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/SOURCES.txt +3 -0
- spec2openapi-0.2.0/tests/test_cli.py +24 -0
- spec2openapi-0.2.0/tests/test_edgecases.py +217 -0
- spec2openapi-0.2.0/tests/test_runtime.py +124 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/LICENSE +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/NOTICE +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/setup.cfg +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/dependency_links.txt +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/entry_points.txt +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/requires.txt +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/src/spec2openapi.egg-info/top_level.txt +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_advanced.py +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_bridge.py +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_convert.py +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_e2e.py +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_fastmcp_compat.py +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_stress.py +0 -0
- {spec2openapi-0.1.0 → spec2openapi-0.2.0}/tests/test_swagger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spec2openapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Convert legacy API specs (SOAP/WSDL, Swagger 2.0) into FastMCP-ready OpenAPI 3.x documents
|
|
5
5
|
Author-email: Seoyul Yoon <devops.reso@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -61,7 +61,14 @@ WSDL ─────────┐
|
|
|
61
61
|
Swagger 2.0 ──┘
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
The
|
|
64
|
+
The two inputs produce two kinds of output — this distinction matters:
|
|
65
|
+
|
|
66
|
+
- **Swagger 2.0 → a plain, standard OpenAPI 3.x document.** The paths are the real REST endpoints. Any OpenAPI-driven runtime (FastMCP, or your own httpx-based server) serves it with **zero runtime changes** — just point it at the converted spec.
|
|
67
|
+
- **WSDL → OpenAPI 3.x + an `x-soap` contract.** The generated `/operations/...` paths are **not real REST endpoints**; each tool call must be serialized to a SOAP envelope, sent to the SOAP endpoint, and the XML response parsed back to JSON. That logic is **not** part of a standard OpenAPI runtime — it lives in the **SOAP bridge shipped in the `[mcp]` extra**. Serving a SOAP-converted spec with a plain OpenAPI/httpx runtime will POST JSON to the SOAP endpoint and fail every call.
|
|
68
|
+
|
|
69
|
+
So `spec2openapi` is a **converter for Swagger 2.0**, and a **converter + runtime contract (with a reference bridge) for SOAP**. See [How SOAP calls work](#how-soap-calls-work-the-x-soap-contract) below.
|
|
70
|
+
|
|
71
|
+
The fixed-runtime deployment model — build one image, swap the spec via a Kubernetes ConfigMap to mass-produce MCP servers — applies to both, as long as the image includes the `[mcp]` extra when serving SOAP specs.
|
|
65
72
|
|
|
66
73
|
## Features
|
|
67
74
|
|
|
@@ -70,15 +77,17 @@ The output is a single, ordinary OpenAPI document. It is designed for a **fixed-
|
|
|
70
77
|
- **`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
78
|
- **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
79
|
- **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
|
-
- **
|
|
80
|
+
- **SOAP bridge — required to *serve* SOAP specs** — `pip install "spec2openapi[mcp]"` adds the bridge (custom httpx transport) that implements the `x-soap` contract, plus FastMCP glue, a fixed Dockerfile, and Kubernetes examples. SOAP faults map to MCP tool errors. **Swagger-converted (pure REST) specs do not need this** — any OpenAPI runtime serves them. Only SOAP-converted specs require the bridge at runtime.
|
|
74
81
|
|
|
75
82
|
## Installation
|
|
76
83
|
|
|
77
84
|
```bash
|
|
78
|
-
pip install spec2openapi # converter
|
|
79
|
-
pip install "spec2openapi[mcp]" # +
|
|
85
|
+
pip install spec2openapi # converter + CLI (zeep, lxml, PyYAML)
|
|
86
|
+
pip install "spec2openapi[mcp]" # + SOAP bridge & runtime — required to serve SOAP specs
|
|
80
87
|
```
|
|
81
88
|
|
|
89
|
+
> The core install is enough to **convert** any spec and to serve **Swagger-converted (REST)** specs from your own runtime. The `[mcp]` extra is required only to **serve SOAP-converted** specs (it provides the bridge that turns JSON tool calls into SOAP envelopes).
|
|
90
|
+
|
|
82
91
|
## Quick start
|
|
83
92
|
|
|
84
93
|
### CLI
|
|
@@ -146,7 +155,9 @@ The generated paths (`/operations/...`) are *not* real REST endpoints — a SOAP
|
|
|
146
155
|
|
|
147
156
|
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
157
|
|
|
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.
|
|
158
|
+
The `[mcp]` extra contains a verified implementation of this contract (`src/spec2openapi/bridge.py`) — use it directly (via `spec2openapi serve`) or as the reference for your own runtime. **There is no way to serve a SOAP-converted spec without an implementation of this contract**; a standard OpenAPI runtime cannot do it.
|
|
159
|
+
|
|
160
|
+
> **Mixed SOAP + REST specs.** The reference runtime routes *all* traffic through the SOAP bridge if *any* path carries `x-soap`, so REST operations in a mixed spec are not served correctly today. Keep SOAP and REST specs separate until this is addressed ([tracking issue](https://github.com/Seo-yul/spec2openapi/issues)).
|
|
150
161
|
|
|
151
162
|
## Handling missing information (Swagger 2.0)
|
|
152
163
|
|
|
@@ -159,7 +170,7 @@ Upgrading is favorable: OpenAPI 3.x is a superset of Swagger 2.0, so almost noth
|
|
|
159
170
|
## Kubernetes: one image, many MCP servers
|
|
160
171
|
|
|
161
172
|
```bash
|
|
162
|
-
docker build -t spec2openapi:0.
|
|
173
|
+
docker build -t spec2openapi:0.2.0 .
|
|
163
174
|
spec2openapi convert <wsdl> -o openapi.yaml
|
|
164
175
|
kubectl create configmap my-mcp-spec --from-file=openapi.yaml
|
|
165
176
|
kubectl apply -f k8s/example.yaml # Deployment mounts /config/openapi.yaml
|
|
@@ -184,7 +195,7 @@ pip install -e ".[dev]"
|
|
|
184
195
|
python -m pytest tests/
|
|
185
196
|
```
|
|
186
197
|
|
|
187
|
-
The suite (
|
|
198
|
+
The suite (103 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
199
|
|
|
189
200
|
## Project layout
|
|
190
201
|
|
|
@@ -20,7 +20,14 @@ WSDL ─────────┐
|
|
|
20
20
|
Swagger 2.0 ──┘
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
The
|
|
23
|
+
The two inputs produce two kinds of output — this distinction matters:
|
|
24
|
+
|
|
25
|
+
- **Swagger 2.0 → a plain, standard OpenAPI 3.x document.** The paths are the real REST endpoints. Any OpenAPI-driven runtime (FastMCP, or your own httpx-based server) serves it with **zero runtime changes** — just point it at the converted spec.
|
|
26
|
+
- **WSDL → OpenAPI 3.x + an `x-soap` contract.** The generated `/operations/...` paths are **not real REST endpoints**; each tool call must be serialized to a SOAP envelope, sent to the SOAP endpoint, and the XML response parsed back to JSON. That logic is **not** part of a standard OpenAPI runtime — it lives in the **SOAP bridge shipped in the `[mcp]` extra**. Serving a SOAP-converted spec with a plain OpenAPI/httpx runtime will POST JSON to the SOAP endpoint and fail every call.
|
|
27
|
+
|
|
28
|
+
So `spec2openapi` is a **converter for Swagger 2.0**, and a **converter + runtime contract (with a reference bridge) for SOAP**. See [How SOAP calls work](#how-soap-calls-work-the-x-soap-contract) below.
|
|
29
|
+
|
|
30
|
+
The fixed-runtime deployment model — build one image, swap the spec via a Kubernetes ConfigMap to mass-produce MCP servers — applies to both, as long as the image includes the `[mcp]` extra when serving SOAP specs.
|
|
24
31
|
|
|
25
32
|
## Features
|
|
26
33
|
|
|
@@ -29,15 +36,17 @@ The output is a single, ordinary OpenAPI document. It is designed for a **fixed-
|
|
|
29
36
|
- **`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.
|
|
30
37
|
- **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`.
|
|
31
38
|
- **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.
|
|
32
|
-
- **
|
|
39
|
+
- **SOAP bridge — required to *serve* SOAP specs** — `pip install "spec2openapi[mcp]"` adds the bridge (custom httpx transport) that implements the `x-soap` contract, plus FastMCP glue, a fixed Dockerfile, and Kubernetes examples. SOAP faults map to MCP tool errors. **Swagger-converted (pure REST) specs do not need this** — any OpenAPI runtime serves them. Only SOAP-converted specs require the bridge at runtime.
|
|
33
40
|
|
|
34
41
|
## Installation
|
|
35
42
|
|
|
36
43
|
```bash
|
|
37
|
-
pip install spec2openapi # converter
|
|
38
|
-
pip install "spec2openapi[mcp]" # +
|
|
44
|
+
pip install spec2openapi # converter + CLI (zeep, lxml, PyYAML)
|
|
45
|
+
pip install "spec2openapi[mcp]" # + SOAP bridge & runtime — required to serve SOAP specs
|
|
39
46
|
```
|
|
40
47
|
|
|
48
|
+
> The core install is enough to **convert** any spec and to serve **Swagger-converted (REST)** specs from your own runtime. The `[mcp]` extra is required only to **serve SOAP-converted** specs (it provides the bridge that turns JSON tool calls into SOAP envelopes).
|
|
49
|
+
|
|
41
50
|
## Quick start
|
|
42
51
|
|
|
43
52
|
### CLI
|
|
@@ -105,7 +114,9 @@ The generated paths (`/operations/...`) are *not* real REST endpoints — a SOAP
|
|
|
105
114
|
|
|
106
115
|
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.
|
|
107
116
|
|
|
108
|
-
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.
|
|
117
|
+
The `[mcp]` extra contains a verified implementation of this contract (`src/spec2openapi/bridge.py`) — use it directly (via `spec2openapi serve`) or as the reference for your own runtime. **There is no way to serve a SOAP-converted spec without an implementation of this contract**; a standard OpenAPI runtime cannot do it.
|
|
118
|
+
|
|
119
|
+
> **Mixed SOAP + REST specs.** The reference runtime routes *all* traffic through the SOAP bridge if *any* path carries `x-soap`, so REST operations in a mixed spec are not served correctly today. Keep SOAP and REST specs separate until this is addressed ([tracking issue](https://github.com/Seo-yul/spec2openapi/issues)).
|
|
109
120
|
|
|
110
121
|
## Handling missing information (Swagger 2.0)
|
|
111
122
|
|
|
@@ -118,7 +129,7 @@ Upgrading is favorable: OpenAPI 3.x is a superset of Swagger 2.0, so almost noth
|
|
|
118
129
|
## Kubernetes: one image, many MCP servers
|
|
119
130
|
|
|
120
131
|
```bash
|
|
121
|
-
docker build -t spec2openapi:0.
|
|
132
|
+
docker build -t spec2openapi:0.2.0 .
|
|
122
133
|
spec2openapi convert <wsdl> -o openapi.yaml
|
|
123
134
|
kubectl create configmap my-mcp-spec --from-file=openapi.yaml
|
|
124
135
|
kubectl apply -f k8s/example.yaml # Deployment mounts /config/openapi.yaml
|
|
@@ -143,7 +154,7 @@ pip install -e ".[dev]"
|
|
|
143
154
|
python -m pytest tests/
|
|
144
155
|
```
|
|
145
156
|
|
|
146
|
-
The suite (
|
|
157
|
+
The suite (103 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/).
|
|
147
158
|
|
|
148
159
|
## Project layout
|
|
149
160
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "spec2openapi"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Convert legacy API specs (SOAP/WSDL, Swagger 2.0) into FastMCP-ready OpenAPI 3.x documents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -7,7 +7,7 @@ Optional MCP runtime (pip install 'spec2openapi[mcp]'): from_openapi_spec,
|
|
|
7
7
|
from_wsdl, BridgeOptions, SoapBridgeTransport.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "0.
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
11
|
|
|
12
12
|
from .convert import convert_wsdl, load_spec, spec_has_soap # noqa: E402,F401
|
|
13
13
|
from .openapi import build_spec, dump_spec, to_openapi_31 # noqa: E402,F401
|
|
@@ -37,6 +37,36 @@ PASSWORD_TEXT = (
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
_FALSEY = {"0", "false", "no", "off", ""}
|
|
41
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
45
|
+
raw = os.getenv(name)
|
|
46
|
+
if raw is None:
|
|
47
|
+
return default
|
|
48
|
+
v = raw.strip().lower()
|
|
49
|
+
if v in _FALSEY:
|
|
50
|
+
return False
|
|
51
|
+
if v in _TRUTHY:
|
|
52
|
+
return True
|
|
53
|
+
logger.warning("env %s=%r not recognized as boolean; using %s",
|
|
54
|
+
name, raw, default)
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _env_float(name: str, default: float) -> float:
|
|
59
|
+
raw = os.getenv(name)
|
|
60
|
+
if raw is None or not raw.strip():
|
|
61
|
+
return default
|
|
62
|
+
try:
|
|
63
|
+
return float(raw)
|
|
64
|
+
except ValueError:
|
|
65
|
+
logger.warning("env %s=%r is not a number; using %s",
|
|
66
|
+
name, raw, default)
|
|
67
|
+
return default
|
|
68
|
+
|
|
69
|
+
|
|
40
70
|
@dataclasses.dataclass
|
|
41
71
|
class BridgeOptions:
|
|
42
72
|
"""Runtime options, typically injected via env vars in the container."""
|
|
@@ -57,10 +87,9 @@ class BridgeOptions:
|
|
|
57
87
|
username=os.getenv("SPEC2OPENAPI_USERNAME") or None,
|
|
58
88
|
password=os.getenv("SPEC2OPENAPI_PASSWORD") or None,
|
|
59
89
|
auth=os.getenv("SPEC2OPENAPI_AUTH") or None,
|
|
60
|
-
timeout=
|
|
61
|
-
verify=
|
|
62
|
-
trust_env=
|
|
63
|
-
not in ("0", "false", "False"),
|
|
90
|
+
timeout=_env_float("SPEC2OPENAPI_TIMEOUT", 30.0),
|
|
91
|
+
verify=_env_bool("SPEC2OPENAPI_VERIFY", True),
|
|
92
|
+
trust_env=_env_bool("SPEC2OPENAPI_TRUST_ENV", True),
|
|
64
93
|
)
|
|
65
94
|
|
|
66
95
|
|
|
@@ -188,6 +217,24 @@ def _write_object(parent: etree._Element, schema: dict, data: Any,
|
|
|
188
217
|
logger.debug("payload key %r not in schema; ignored", name)
|
|
189
218
|
|
|
190
219
|
|
|
220
|
+
def _choice_violations(schema: dict[str, Any], data: Any) -> list[str]:
|
|
221
|
+
"""Enforce x-soap-choice on the payload: at most one member per group,
|
|
222
|
+
and exactly one when the group is required."""
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
return []
|
|
225
|
+
errors: list[str] = []
|
|
226
|
+
for group in schema.get("x-soap-choice", []) or []:
|
|
227
|
+
members = group.get("members", [])
|
|
228
|
+
present = [m for m in members if data.get(m) is not None]
|
|
229
|
+
if len(present) > 1:
|
|
230
|
+
errors.append(
|
|
231
|
+
f"at most one of {members} may be set (got {present})"
|
|
232
|
+
)
|
|
233
|
+
elif group.get("required") and not present:
|
|
234
|
+
errors.append(f"exactly one of {members} is required")
|
|
235
|
+
return errors
|
|
236
|
+
|
|
237
|
+
|
|
191
238
|
def build_envelope(op: dict[str, Any], payload: dict[str, Any],
|
|
192
239
|
index: _SpecIndex, options: BridgeOptions) -> bytes:
|
|
193
240
|
xsoap = op["x-soap"]
|
|
@@ -246,7 +293,12 @@ def _coerce(text: str | None, schema: dict[str, Any]) -> Any:
|
|
|
246
293
|
if t == "number":
|
|
247
294
|
return float(s)
|
|
248
295
|
if t == "boolean":
|
|
249
|
-
|
|
296
|
+
low = s.lower()
|
|
297
|
+
if low in ("true", "1"):
|
|
298
|
+
return True
|
|
299
|
+
if low in ("false", "0"):
|
|
300
|
+
return False
|
|
301
|
+
return text # non-canonical: don't silently coerce to False
|
|
250
302
|
except ValueError:
|
|
251
303
|
return text
|
|
252
304
|
return text if t == "string" else (text if s == "" else s)
|
|
@@ -328,17 +380,43 @@ def parse_fault(body: etree._Element, env_ns: str) -> dict[str, Any] | None:
|
|
|
328
380
|
return {"faultcode": code, "faultstring": reason, "detail": detail}
|
|
329
381
|
|
|
330
382
|
|
|
383
|
+
def _http_error(http_status: int, content: bytes) -> tuple[int, dict[str, Any]]:
|
|
384
|
+
return 502, {
|
|
385
|
+
"faultcode": "spec2openapi.HTTPError",
|
|
386
|
+
"faultstring": f"endpoint returned HTTP {http_status}",
|
|
387
|
+
"detail": content[:2000].decode("utf-8", "replace"),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
331
391
|
def parse_response(content: bytes, op: dict[str, Any],
|
|
332
|
-
index: _SpecIndex
|
|
333
|
-
|
|
392
|
+
index: _SpecIndex,
|
|
393
|
+
http_status: int = 200) -> tuple[int, dict[str, Any]]:
|
|
394
|
+
"""Returns (mapped_status, json_payload). http_status is the transport
|
|
395
|
+
status code, used to give sensible errors for empty/non-XML bodies."""
|
|
334
396
|
xsoap = op["x-soap"]
|
|
397
|
+
is_one_way = not xsoap.get("output")
|
|
335
398
|
env_ns = SOAP_ENV_NS.get(xsoap.get("soapVersion", "1.1"), SOAP_ENV_NS["1.1"])
|
|
399
|
+
|
|
400
|
+
if not content or not content.strip():
|
|
401
|
+
# empty body: a one-way call with a 2xx succeeded; otherwise the
|
|
402
|
+
# HTTP status carries the real story
|
|
403
|
+
if http_status >= 400:
|
|
404
|
+
return _http_error(http_status, content)
|
|
405
|
+
if is_one_way:
|
|
406
|
+
return 200, {}
|
|
407
|
+
return 502, {
|
|
408
|
+
"faultcode": "spec2openapi.EmptyResponse",
|
|
409
|
+
"faultstring": f"endpoint returned an empty body (HTTP {http_status})",
|
|
410
|
+
"detail": "",
|
|
411
|
+
}
|
|
336
412
|
try:
|
|
337
413
|
# endpoint responses are untrusted: no entities, DTDs, or network
|
|
338
414
|
parser = etree.XMLParser(resolve_entities=False, load_dtd=False,
|
|
339
415
|
no_network=True, huge_tree=False)
|
|
340
416
|
tree = etree.fromstring(content, parser=parser)
|
|
341
417
|
except Exception as exc:
|
|
418
|
+
if http_status >= 400:
|
|
419
|
+
return _http_error(http_status, content)
|
|
342
420
|
return 502, {
|
|
343
421
|
"faultcode": "spec2openapi.InvalidXML",
|
|
344
422
|
"faultstring": f"endpoint returned non-XML response: {exc}",
|
|
@@ -353,6 +431,8 @@ def parse_response(content: bytes, op: dict[str, Any],
|
|
|
353
431
|
env_ns = alt
|
|
354
432
|
break
|
|
355
433
|
if body is None:
|
|
434
|
+
if http_status >= 400:
|
|
435
|
+
return _http_error(http_status, content)
|
|
356
436
|
return 502, {
|
|
357
437
|
"faultcode": "spec2openapi.NoBody",
|
|
358
438
|
"faultstring": "no SOAP Body found in response",
|
|
@@ -444,6 +524,16 @@ class SoapBridgeTransport(httpx.AsyncBaseTransport):
|
|
|
444
524
|
"detail": ""},
|
|
445
525
|
)
|
|
446
526
|
|
|
527
|
+
violations = _choice_violations(
|
|
528
|
+
self.index.deref(op.get("input", {})), payload
|
|
529
|
+
)
|
|
530
|
+
if violations:
|
|
531
|
+
return self._json_response(
|
|
532
|
+
request, 400,
|
|
533
|
+
{"faultcode": "spec2openapi.ChoiceViolation",
|
|
534
|
+
"faultstring": "; ".join(violations), "detail": ""},
|
|
535
|
+
)
|
|
536
|
+
|
|
447
537
|
envelope = build_envelope(op, payload, self.index, self.options)
|
|
448
538
|
action = xsoap.get("soapAction", "")
|
|
449
539
|
if xsoap.get("soapVersion") == "1.2":
|
|
@@ -468,11 +558,7 @@ class SoapBridgeTransport(httpx.AsyncBaseTransport):
|
|
|
468
558
|
"faultstring": f"SOAP endpoint unreachable: {exc}", "detail": ""},
|
|
469
559
|
)
|
|
470
560
|
|
|
471
|
-
status, data = parse_response(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
status = 502
|
|
475
|
-
data = {"faultcode": "spec2openapi.HTTPError",
|
|
476
|
-
"faultstring": f"endpoint returned HTTP {soap_resp.status_code}",
|
|
477
|
-
"detail": soap_resp.text[:2000]}
|
|
561
|
+
status, data = parse_response(
|
|
562
|
+
soap_resp.content, op, self.index, soap_resp.status_code
|
|
563
|
+
)
|
|
478
564
|
return self._json_response(request, status, data)
|
|
@@ -17,6 +17,10 @@ from . import __version__
|
|
|
17
17
|
|
|
18
18
|
_MCP_HINT = "install the MCP runtime extras first: pip install 'spec2openapi[mcp]'"
|
|
19
19
|
|
|
20
|
+
_HTTP_METHODS = frozenset(
|
|
21
|
+
("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
22
|
+
)
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
def _is_wsdl_source(src: str) -> bool:
|
|
22
26
|
low = src.lower()
|
|
@@ -28,8 +32,12 @@ def _is_wsdl_source(src: str) -> bool:
|
|
|
28
32
|
return True
|
|
29
33
|
p = Path(src)
|
|
30
34
|
if p.exists():
|
|
31
|
-
head = p.read_text(encoding="utf-8", errors="replace")[:512].lstrip()
|
|
35
|
+
head = p.read_text(encoding="utf-8-sig", errors="replace")[:512].lstrip()
|
|
32
36
|
return head.startswith("<")
|
|
37
|
+
# a remote URL without a spec extension: zeep can fetch WSDLs, and
|
|
38
|
+
# load_spec cannot read a URL, so treat it as WSDL
|
|
39
|
+
if low.startswith(("http://", "https://")):
|
|
40
|
+
return True
|
|
33
41
|
return False
|
|
34
42
|
|
|
35
43
|
|
|
@@ -89,7 +97,7 @@ def cmd_convert(args) -> int:
|
|
|
89
97
|
openapi_version=args.openapi_version,
|
|
90
98
|
forbid_external=args.forbid_external, huge_tree=args.huge_tree,
|
|
91
99
|
)
|
|
92
|
-
fmt = args.format or ("json" if (args.output or "").endswith(".json") else "yaml")
|
|
100
|
+
fmt = args.format or ("json" if (args.output or "").lower().endswith(".json") else "yaml")
|
|
93
101
|
text = dump_spec(spec, fmt)
|
|
94
102
|
if args.output:
|
|
95
103
|
Path(args.output).write_text(text, encoding="utf-8")
|
|
@@ -139,7 +147,7 @@ def cmd_upgrade(args) -> int:
|
|
|
139
147
|
print(f"{kind[:-1] if kind.endswith('s') else kind}: {msg}",
|
|
140
148
|
file=sys.stderr)
|
|
141
149
|
|
|
142
|
-
fmt = args.format or ("json" if (args.output or "").endswith(".json") else "yaml")
|
|
150
|
+
fmt = args.format or ("json" if (args.output or "").lower().endswith(".json") else "yaml")
|
|
143
151
|
text = dump_spec(upgraded, fmt)
|
|
144
152
|
if args.output:
|
|
145
153
|
Path(args.output).write_text(text, encoding="utf-8")
|
|
@@ -164,8 +172,11 @@ def cmd_validate(args) -> int:
|
|
|
164
172
|
problems.append("spec has no paths")
|
|
165
173
|
op_ids: list[str] = []
|
|
166
174
|
for path, item in paths.items():
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
if not isinstance(item, dict):
|
|
176
|
+
continue
|
|
177
|
+
for method, op in item.items():
|
|
178
|
+
# only HTTP methods are operations; skip parameters/$ref/x- keys
|
|
179
|
+
if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
|
|
169
180
|
continue
|
|
170
181
|
oid = op.get("operationId")
|
|
171
182
|
if not oid:
|
|
@@ -174,7 +185,8 @@ def cmd_validate(args) -> int:
|
|
|
174
185
|
op_ids.append(oid)
|
|
175
186
|
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", oid):
|
|
176
187
|
problems.append(f"{oid}: not a safe MCP tool name")
|
|
177
|
-
|
|
188
|
+
xsoap = op.get("x-soap")
|
|
189
|
+
if isinstance(xsoap, dict) and not xsoap.get("input", {}).get("element"):
|
|
178
190
|
problems.append(f"{oid}: x-soap.input.element missing")
|
|
179
191
|
dupes = {o for o in op_ids if op_ids.count(o) > 1}
|
|
180
192
|
if dupes:
|
|
@@ -195,9 +207,15 @@ def cmd_validate(args) -> int:
|
|
|
195
207
|
|
|
196
208
|
try: # FastMCP round-trip: the compatibility this project guarantees
|
|
197
209
|
import anyio
|
|
210
|
+
import httpx
|
|
198
211
|
from fastmcp import Client, FastMCP
|
|
199
212
|
|
|
200
|
-
|
|
213
|
+
# supply a dummy client so specs without a `servers` entry still
|
|
214
|
+
# convert — validate measures tool convertibility, not deployment
|
|
215
|
+
dummy = httpx.AsyncClient(base_url="http://spec2openapi.invalid")
|
|
216
|
+
mcp = FastMCP.from_openapi(
|
|
217
|
+
openapi_spec=spec, name="validate", client=dummy
|
|
218
|
+
)
|
|
201
219
|
|
|
202
220
|
async def _tools():
|
|
203
221
|
async with Client(mcp) as client:
|
|
@@ -238,17 +256,19 @@ def cmd_validate(args) -> int:
|
|
|
238
256
|
|
|
239
257
|
|
|
240
258
|
def cmd_serve(args) -> int:
|
|
259
|
+
spec = _load_or_convert(args.source)
|
|
241
260
|
try:
|
|
261
|
+
# everything [mcp]-flavored lives inside the guard: server, the
|
|
262
|
+
# bridge import in _bridge_options, and fastmcp's lazy imports
|
|
242
263
|
from .server import from_openapi_spec
|
|
264
|
+
|
|
265
|
+
mcp = from_openapi_spec(
|
|
266
|
+
spec, options=_bridge_options(args),
|
|
267
|
+
validate_output=args.validate_output,
|
|
268
|
+
)
|
|
243
269
|
except ImportError:
|
|
244
270
|
print(f"error: {_MCP_HINT}", file=sys.stderr)
|
|
245
271
|
return 2
|
|
246
|
-
|
|
247
|
-
spec = _load_or_convert(args.source)
|
|
248
|
-
mcp = from_openapi_spec(
|
|
249
|
-
spec, options=_bridge_options(args),
|
|
250
|
-
validate_output=args.validate_output,
|
|
251
|
-
)
|
|
252
272
|
if args.transport == "http":
|
|
253
273
|
mcp.run(transport="http", host=args.host, port=args.port,
|
|
254
274
|
path=args.path, show_banner=False)
|
|
@@ -323,7 +343,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
323
343
|
s.set_defaults(fn=cmd_serve)
|
|
324
344
|
|
|
325
345
|
args = ap.parse_args(argv)
|
|
326
|
-
|
|
346
|
+
try:
|
|
347
|
+
return args.fn(args)
|
|
348
|
+
except (FileNotFoundError, ValueError, OSError) as exc:
|
|
349
|
+
# expected user-input errors: one-line message, not a traceback
|
|
350
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
351
|
+
return 2
|
|
327
352
|
|
|
328
353
|
|
|
329
354
|
if __name__ == "__main__":
|
|
@@ -44,14 +44,21 @@ def convert_wsdl(
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def load_spec(path: str | Path) -> dict[str, Any]:
|
|
47
|
-
"""Load an OpenAPI spec from a .yaml/.yml/.json file."""
|
|
47
|
+
"""Load an OpenAPI/Swagger spec from a .yaml/.yml/.json file."""
|
|
48
48
|
p = Path(path)
|
|
49
|
-
text = p.read_text(encoding="utf-8")
|
|
49
|
+
text = p.read_text(encoding="utf-8-sig")
|
|
50
50
|
if p.suffix.lower() == ".json" or text.lstrip().startswith("{"):
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
spec = json.loads(text)
|
|
52
|
+
else:
|
|
53
|
+
import yaml
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
spec = yaml.safe_load(text)
|
|
56
|
+
if not isinstance(spec, dict):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{p}: not a valid OpenAPI/Swagger document "
|
|
59
|
+
f"(parsed as {type(spec).__name__}, expected a mapping)"
|
|
60
|
+
)
|
|
61
|
+
return spec
|
|
55
62
|
|
|
56
63
|
|
|
57
64
|
def spec_has_soap(spec: dict[str, Any]) -> bool:
|
|
@@ -25,6 +25,28 @@ import re
|
|
|
25
25
|
from .schema import SchemaConverter, sanitize_name
|
|
26
26
|
|
|
27
27
|
_TOOL_ID_RE = re.compile(r"[^A-Za-z0-9_]+")
|
|
28
|
+
_MAX_ID_LEN = 64
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tool_id(raw: str) -> str:
|
|
32
|
+
"""Normalize to FastMCP's tool-name alphabet, bounded to 64 chars."""
|
|
33
|
+
tid = _TOOL_ID_RE.sub("_", sanitize_name(raw)).strip("_")
|
|
34
|
+
return tid[:_MAX_ID_LEN] if tid else "op"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _unique_id(base: str, used: set[str]) -> str:
|
|
38
|
+
"""Make base unique within `used`, keeping the result <= 64 chars."""
|
|
39
|
+
if base not in used:
|
|
40
|
+
used.add(base)
|
|
41
|
+
return base
|
|
42
|
+
n = 2
|
|
43
|
+
while True:
|
|
44
|
+
suffix = f"_{n}"
|
|
45
|
+
candidate = base[: _MAX_ID_LEN - len(suffix)] + suffix
|
|
46
|
+
if candidate not in used:
|
|
47
|
+
used.add(candidate)
|
|
48
|
+
return candidate
|
|
49
|
+
n += 1
|
|
28
50
|
|
|
29
51
|
SOAP_FAULT_SCHEMA = {
|
|
30
52
|
"type": "object",
|
|
@@ -64,11 +86,20 @@ def build_spec(
|
|
|
64
86
|
) -> dict[str, Any]:
|
|
65
87
|
conv = SchemaConverter(parsed.xsd_meta)
|
|
66
88
|
paths: dict[str, Any] = {}
|
|
89
|
+
used_ids: set[str] = set()
|
|
90
|
+
|
|
91
|
+
# reserve the built-in fault schema name up front so a WSDL type also
|
|
92
|
+
# named "SoapFault" is deduped to another name instead of clobbering it
|
|
93
|
+
fault_ref_name = "SoapFault"
|
|
94
|
+
conv.components[fault_ref_name] = SOAP_FAULT_SCHEMA
|
|
95
|
+
fault_ref = f"#/components/schemas/{fault_ref_name}"
|
|
67
96
|
|
|
68
97
|
for op in parsed.operations:
|
|
69
98
|
# FastMCP normalizes tool names to [A-Za-z0-9_]; emit operationIds
|
|
70
|
-
# in that alphabet
|
|
71
|
-
|
|
99
|
+
# in that alphabet, bounded to 64 chars, and re-checked for
|
|
100
|
+
# uniqueness *after* normalization/truncation so two operations
|
|
101
|
+
# never collide onto the same path (which would drop one).
|
|
102
|
+
op_id = _unique_id(_tool_id(op.op_id), used_ids)
|
|
72
103
|
in_q = _element_qname(op.input_element)
|
|
73
104
|
in_schema = conv.element_type_to_object_schema(
|
|
74
105
|
op.input_element.type,
|
|
@@ -163,7 +194,7 @@ def build_spec(
|
|
|
163
194
|
),
|
|
164
195
|
"content": {
|
|
165
196
|
"application/json": {
|
|
166
|
-
"schema": {"$ref":
|
|
197
|
+
"schema": {"$ref": fault_ref}
|
|
167
198
|
}
|
|
168
199
|
},
|
|
169
200
|
},
|
|
@@ -172,8 +203,7 @@ def build_spec(
|
|
|
172
203
|
paths[f"{base_path}/{op_id}"] = {"post": post}
|
|
173
204
|
|
|
174
205
|
endpoint = parsed.operations[0].endpoint if parsed.operations else ""
|
|
175
|
-
components = dict(conv.components)
|
|
176
|
-
components["SoapFault"] = SOAP_FAULT_SCHEMA
|
|
206
|
+
components = dict(conv.components) # already includes the fault schema
|
|
177
207
|
|
|
178
208
|
spec: dict[str, Any] = {
|
|
179
209
|
"openapi": "3.0.3",
|
|
@@ -203,20 +233,31 @@ def build_spec(
|
|
|
203
233
|
def to_openapi_31(spec: dict[str, Any]) -> dict[str, Any]:
|
|
204
234
|
"""Convert the generated 3.0 document to OpenAPI 3.1 JSON Schema style."""
|
|
205
235
|
|
|
236
|
+
# keywords whose values are data, not sub-schemas — don't descend
|
|
237
|
+
data_kw = ("example", "examples", "default", "enum")
|
|
238
|
+
|
|
206
239
|
def walk(node: Any) -> Any:
|
|
207
240
|
if isinstance(node, list):
|
|
208
241
|
return [walk(v) for v in node]
|
|
209
242
|
if not isinstance(node, dict):
|
|
210
243
|
return node
|
|
211
|
-
node = {k: walk(v) for k, v in node.items()}
|
|
244
|
+
node = {k: (v if k in data_kw else walk(v)) for k, v in node.items()}
|
|
212
245
|
if node.pop("nullable", False):
|
|
213
246
|
t = node.get("type")
|
|
214
247
|
if isinstance(t, str):
|
|
215
248
|
node["type"] = [t, "null"]
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
249
|
+
# 3.0 uses boolean exclusiveMinimum/Maximum alongside minimum/maximum;
|
|
250
|
+
# 2020-12 requires a number. Convert true+bound, and drop the boolean
|
|
251
|
+
# otherwise (false = inclusive default; true without a bound is
|
|
252
|
+
# malformed and cannot be represented).
|
|
253
|
+
for kw, bound in (("exclusiveMinimum", "minimum"),
|
|
254
|
+
("exclusiveMaximum", "maximum")):
|
|
255
|
+
val = node.get(kw)
|
|
256
|
+
if isinstance(val, bool):
|
|
257
|
+
if val and bound in node:
|
|
258
|
+
node[kw] = node.pop(bound)
|
|
259
|
+
else:
|
|
260
|
+
node.pop(kw, None)
|
|
220
261
|
return node
|
|
221
262
|
|
|
222
263
|
out = walk(dict(spec))
|
|
@@ -303,7 +303,12 @@ def parse_wsdl(
|
|
|
303
303
|
forbid_dtd=True, forbid_entities=True,
|
|
304
304
|
forbid_external=forbid_external,
|
|
305
305
|
)
|
|
306
|
-
|
|
306
|
+
try:
|
|
307
|
+
client = Client(source, settings=settings)
|
|
308
|
+
except FileNotFoundError:
|
|
309
|
+
raise
|
|
310
|
+
except Exception as exc: # malformed/unfetchable WSDL -> clean error
|
|
311
|
+
raise ValueError(f"could not parse WSDL '{source}': {exc}") from exc
|
|
307
312
|
|
|
308
313
|
svc_doc, op_docs = _extract_wsdl_docs(source)
|
|
309
314
|
xsd_meta = _collect_xsd_meta(client, source, forbid_external=forbid_external)
|