oed-cli 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.
- oed_cli/__init__.py +6 -0
- oed_cli/__main__.py +6 -0
- oed_cli/cli.py +278 -0
- oed_cli/discovery.py +168 -0
- oed_cli/dynamic.py +523 -0
- oed_cli/errors.py +45 -0
- oed_cli/http.py +187 -0
- oed_cli/invoke.py +319 -0
- oed_cli/main.py +414 -0
- oed_cli/py.typed +0 -0
- oed_cli-0.1.0.dist-info/METADATA +373 -0
- oed_cli-0.1.0.dist-info/RECORD +16 -0
- oed_cli-0.1.0.dist-info/WHEEL +5 -0
- oed_cli-0.1.0.dist-info/entry_points.txt +2 -0
- oed_cli-0.1.0.dist-info/licenses/LICENSE +17 -0
- oed_cli-0.1.0.dist-info/top_level.txt +1 -0
oed_cli/http.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""WAF-safe HTTP client for the openEuler Infra gateway.
|
|
2
|
+
|
|
3
|
+
The gateway sits behind a CloudWAF that returns a Chinese-language HTML block
|
|
4
|
+
page when the request lacks browser-style headers (see ``context/discoverAPI.md``
|
|
5
|
+
section "注意事项 & 已知限制"). This module bakes those headers in so every call
|
|
6
|
+
made by ``oed`` succeeds without users tweaking curl flags.
|
|
7
|
+
|
|
8
|
+
Note on ``Referer``: openEuler APIG rejects requests that carry a
|
|
9
|
+
``Referer: https://api-gateway.osinfra.cn/`` header with HTTP 401 on at
|
|
10
|
+
least one production path (``easysearch`` / ``sigsearch/docs``). The
|
|
11
|
+
``/discovery/apis`` feed does not need Referer to pass the CloudWAF
|
|
12
|
+
either — empirically verified 2026-07-28. So ``_headers()`` does NOT
|
|
13
|
+
emit a default Referer. Callers can still pass one explicitly via the
|
|
14
|
+
``headers=`` argument to :func:`get_request` if a future endpoint
|
|
15
|
+
demands it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .errors import NetworkError, NotFoundError, UpstreamError
|
|
27
|
+
|
|
28
|
+
DEFAULT_GATEWAY = "https://api-gateway.osinfra.cn"
|
|
29
|
+
_TIMEOUT_SECONDS = 30.0
|
|
30
|
+
DEFAULT_USER_AGENT = f"oed/{__version__} (+https://atomgit.com/openeuler/oed-cli)"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_user_agent(user_agent: str | None) -> str:
|
|
34
|
+
"""Resolve the User-Agent: explicit arg > ``OED_USER_AGENT`` env > default.
|
|
35
|
+
|
|
36
|
+
An empty string falls through to the next source — passing ``--user-agent ""``
|
|
37
|
+
is treated the same as not passing it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
if user_agent:
|
|
41
|
+
return user_agent
|
|
42
|
+
env = os.environ.get("OED_USER_AGENT")
|
|
43
|
+
if env:
|
|
44
|
+
return env
|
|
45
|
+
return DEFAULT_USER_AGENT
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _headers(
|
|
49
|
+
extra: dict[str, str] | None = None, *, user_agent: str | None = None
|
|
50
|
+
) -> dict[str, str]:
|
|
51
|
+
h = {
|
|
52
|
+
"User-Agent": _resolve_user_agent(user_agent),
|
|
53
|
+
"Accept": "application/json, text/plain, */*",
|
|
54
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
55
|
+
}
|
|
56
|
+
if extra:
|
|
57
|
+
h.update(extra)
|
|
58
|
+
return h
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_waf_block(html: str) -> bool:
|
|
62
|
+
lowered = html[:512].lower()
|
|
63
|
+
return (
|
|
64
|
+
"<!doctype html" in lowered
|
|
65
|
+
and ("cloudwaf" in lowered or "访问被拦截" in html or "requestid" in lowered)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_json(url: str, *, params: dict | None = None, headers: dict | None = None) -> Any:
|
|
70
|
+
"""GET ``url`` and return parsed JSON.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
|
|
74
|
+
- :class:`NetworkError` (exit 2) for connectivity / WAF failures
|
|
75
|
+
- :class:`UpstreamError` (exit 3) for genuine 5xx / unexpected payloads
|
|
76
|
+
- :class:`NotFoundError` (exit 4) when the upstream reports an empty spec
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
return _decode(get_request("GET", url, params=params, headers=headers), spec_endpoint=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_request(
|
|
83
|
+
method: str,
|
|
84
|
+
url: str,
|
|
85
|
+
*,
|
|
86
|
+
params: dict | None = None,
|
|
87
|
+
body: Any = None,
|
|
88
|
+
headers: dict | None = None,
|
|
89
|
+
timeout: float = _TIMEOUT_SECONDS,
|
|
90
|
+
user_agent: str | None = None,
|
|
91
|
+
) -> httpx.Response:
|
|
92
|
+
"""Run an arbitrary HTTP request and return the raw :class:`httpx.Response`.
|
|
93
|
+
|
|
94
|
+
Adds the WAF-safe browser headers; auto-declares ``Content-Type: application/json``
|
|
95
|
+
when ``body`` is set and the caller has not overridden it. Surfaces
|
|
96
|
+
connectivity failures as :class:`NetworkError`; WAF blocks as
|
|
97
|
+
:class:`NetworkError` (kind ``waf_block``); 5xx as :class:`UpstreamError`.
|
|
98
|
+
|
|
99
|
+
``user_agent`` overrides the default User-Agent header — useful when a
|
|
100
|
+
specific APIG backend's WAF rejects the default ``oed/x.y.z`` UA with a
|
|
101
|
+
misleading 401 (e.g. ``easysearch``). Falls back to ``OED_USER_AGENT`` env,
|
|
102
|
+
then the bundled default.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
method = method.upper()
|
|
106
|
+
extra = dict(headers or {})
|
|
107
|
+
if body is not None and not any(h.lower() == "content-type" for h in extra):
|
|
108
|
+
extra["Content-Type"] = "application/json"
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
112
|
+
return client.request(
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
params=params if params else None,
|
|
116
|
+
json=body if body is not None else None,
|
|
117
|
+
headers=_headers(extra, user_agent=user_agent),
|
|
118
|
+
)
|
|
119
|
+
except httpx.HTTPError as exc:
|
|
120
|
+
raise NetworkError(f"{method} {url} failed: {exc}", kind="network_error") from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _decode(
|
|
124
|
+
resp: httpx.Response,
|
|
125
|
+
*,
|
|
126
|
+
spec_endpoint: bool = False,
|
|
127
|
+
) -> Any:
|
|
128
|
+
"""Validate ``resp`` and return parsed JSON.
|
|
129
|
+
|
|
130
|
+
``spec_endpoint=True`` treats an empty body as :class:`NotFoundError`
|
|
131
|
+
(used for spec discovery). For runtime service calls an empty body is
|
|
132
|
+
considered a successful empty payload (``null``).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
if resp.status_code >= 500:
|
|
136
|
+
raise UpstreamError(
|
|
137
|
+
f"{resp.request.method} {resp.url} returned {resp.status_code}",
|
|
138
|
+
kind="upstream_error",
|
|
139
|
+
hint="Check gateway status; retry shortly.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if _is_waf_block(resp.text):
|
|
143
|
+
raise NetworkError(
|
|
144
|
+
f"Gateway WAF blocked the request to {resp.url}",
|
|
145
|
+
kind="waf_block",
|
|
146
|
+
hint=(
|
|
147
|
+
"Some APIG backends reject the bundled oed/x.y.z User-Agent. "
|
|
148
|
+
"Retry with `--user-agent 'Mozilla/5.0 ...'` or set "
|
|
149
|
+
"OED_USER_AGENT in the environment."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if not resp.content:
|
|
154
|
+
if spec_endpoint:
|
|
155
|
+
raise NotFoundError(
|
|
156
|
+
f"{resp.request.method} {resp.url} "
|
|
157
|
+
f"returned an empty body (HTTP {resp.status_code})",
|
|
158
|
+
kind="spec_missing",
|
|
159
|
+
hint=(
|
|
160
|
+
"The discovery feed lists this service but its OpenAPI "
|
|
161
|
+
"spec has not been published yet."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return resp.json()
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise UpstreamError(
|
|
170
|
+
f"{resp.request.method} {resp.url} returned non-JSON body (HTTP {resp.status_code})",
|
|
171
|
+
kind="upstream_error",
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def request_json(
|
|
176
|
+
method: str,
|
|
177
|
+
url: str,
|
|
178
|
+
*,
|
|
179
|
+
params: dict | None = None,
|
|
180
|
+
body: Any = None,
|
|
181
|
+
headers: dict | None = None,
|
|
182
|
+
timeout: float = _TIMEOUT_SECONDS,
|
|
183
|
+
) -> Any:
|
|
184
|
+
"""Run a request and return parsed JSON (empty body → ``None``)."""
|
|
185
|
+
|
|
186
|
+
resp = get_request(method, url, params=params, body=body, headers=headers, timeout=timeout)
|
|
187
|
+
return _decode(resp)
|
oed_cli/invoke.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Run a real :class:`Operation` against its backend.
|
|
2
|
+
|
|
3
|
+
This module is what the CLI dispatches to when a user runs
|
|
4
|
+
``oed <service> <method> --params ... --json ...``. It fills path/query
|
|
5
|
+
params on the OpenAPI ``paths`` template, attaches the JSON body, and
|
|
6
|
+
sends the request through the production gateway via
|
|
7
|
+
``oed_cli.http.get_request``.
|
|
8
|
+
|
|
9
|
+
URL construction note: the spec's ``x-apigateway-backend.httpEndpoints``
|
|
10
|
+
block is parsed for ``method`` / ``scheme`` but **not** trusted for the
|
|
11
|
+
host — those ``address`` values are routinely staging hosts
|
|
12
|
+
(``*.test.osinfra.cn``) that CloudWAF blocks. The runtime URL is built
|
|
13
|
+
as ``op.base_url + op.path``, where ``op.base_url`` was filled in by
|
|
14
|
+
:func:`oed_cli.dynamic.resolve_runtime_gateway` from the discovery feed
|
|
15
|
+
(``ServiceMeta.base_url``). ``op.path`` is the OpenAPI ``paths`` key
|
|
16
|
+
(e.g. ``/cve-security-notice-server/securitynotice/getByCveId``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from . import http as http_mod
|
|
27
|
+
from .dynamic import Operation, coerce_param_types, to_flag
|
|
28
|
+
from .errors import NetworkError, UserError
|
|
29
|
+
from .http import _is_waf_block, _resolve_user_agent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fill_path(template: str, params: dict[str, Any]) -> tuple[str, list[str]]:
|
|
33
|
+
"""Substitute ``{name}`` placeholders in ``template`` from ``params``.
|
|
34
|
+
|
|
35
|
+
Returns the rendered path and the list of placeholder names that were
|
|
36
|
+
not provided, so the caller can raise a precise error.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
missing: list[str] = []
|
|
40
|
+
parts: list[str] = []
|
|
41
|
+
rest = template
|
|
42
|
+
while True:
|
|
43
|
+
head, sep, chunk = rest.partition("{")
|
|
44
|
+
if not sep:
|
|
45
|
+
parts.append(head)
|
|
46
|
+
break
|
|
47
|
+
name_end = chunk.find("}")
|
|
48
|
+
if name_end == -1:
|
|
49
|
+
parts.append(head + sep + chunk)
|
|
50
|
+
break
|
|
51
|
+
name = chunk[:name_end]
|
|
52
|
+
parts.append(head)
|
|
53
|
+
if name in params:
|
|
54
|
+
parts.append(str(params[name]))
|
|
55
|
+
else:
|
|
56
|
+
missing.append(name)
|
|
57
|
+
parts.append("{" + name + "}")
|
|
58
|
+
rest = chunk[name_end + 1 :]
|
|
59
|
+
return "".join(parts), missing
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _select_params(
|
|
63
|
+
op: Operation, raw: dict[str, Any] | None
|
|
64
|
+
) -> tuple[dict, dict, list[str]]:
|
|
65
|
+
"""Split ``raw`` into (path_params, query_params, unused_keys)."""
|
|
66
|
+
|
|
67
|
+
raw = raw or {}
|
|
68
|
+
path_names = {p["name"] for p in op.path_params}
|
|
69
|
+
query_names = {p.get("name") for p in op.query_params}
|
|
70
|
+
declared = path_names | query_names
|
|
71
|
+
|
|
72
|
+
path_params = {k: raw[k] for k in path_names if k in raw}
|
|
73
|
+
query_params = {k: raw[k] for k in raw if k in query_names and k not in path_params}
|
|
74
|
+
unused = sorted(k for k in raw if k not in declared)
|
|
75
|
+
return path_params, query_params, unused
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _render_response(resp: httpx.Response) -> Any:
|
|
79
|
+
"""Parse the JSON body of ``resp``, returning a wrapped string for non-JSON."""
|
|
80
|
+
|
|
81
|
+
if not resp.content:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
return resp.json()
|
|
85
|
+
except Exception:
|
|
86
|
+
return {
|
|
87
|
+
"_non_json_body": resp.text[:8192],
|
|
88
|
+
"_content_type": resp.headers.get("content-type", ""),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def call_operation(
|
|
93
|
+
op: Operation,
|
|
94
|
+
*,
|
|
95
|
+
params: dict[str, Any] | None = None,
|
|
96
|
+
body: Any = None,
|
|
97
|
+
dry_run: bool = False,
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
include_request: bool = False,
|
|
100
|
+
user_agent: str | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Invoke ``op`` and return a structured JSON dict suitable for stdout.
|
|
103
|
+
|
|
104
|
+
``ok`` is ``True`` for any 2xx/3xx response. Non-success still surfaces
|
|
105
|
+
the body and status under ``response`` / ``status``; the caller is
|
|
106
|
+
responsible for the exit code.
|
|
107
|
+
|
|
108
|
+
``user_agent`` overrides the default User-Agent header; falls back to
|
|
109
|
+
``OED_USER_AGENT`` env, then the bundled default.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
path_params, query_params, unused = _select_params(op, params)
|
|
113
|
+
query_params = coerce_param_types(op, query_params)
|
|
114
|
+
|
|
115
|
+
filled_path, missing = _fill_path(op.path, path_params)
|
|
116
|
+
if missing:
|
|
117
|
+
raise UserError(
|
|
118
|
+
f"missing path params: {missing}",
|
|
119
|
+
kind="missing_path_param",
|
|
120
|
+
hint=f"Provide them via --params: {', '.join(missing)}",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
query_params = {k: v for k, v in query_params.items() if v is not None}
|
|
124
|
+
url = f"{op.base_url}{filled_path}"
|
|
125
|
+
|
|
126
|
+
request_headers: dict[str, str] = {"User-Agent": _resolve_user_agent(user_agent)}
|
|
127
|
+
if body is not None:
|
|
128
|
+
request_headers["Content-Type"] = "application/json"
|
|
129
|
+
request_view: dict[str, Any] = {
|
|
130
|
+
"method": op.backend.method,
|
|
131
|
+
"url": url,
|
|
132
|
+
"query": query_params,
|
|
133
|
+
"headers": request_headers,
|
|
134
|
+
"body": body,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if dry_run:
|
|
138
|
+
out = {
|
|
139
|
+
"ok": True,
|
|
140
|
+
"dry_run": True,
|
|
141
|
+
"service": op.service_name,
|
|
142
|
+
"operation": op.display_name,
|
|
143
|
+
"method": op.backend.method,
|
|
144
|
+
"url": url,
|
|
145
|
+
"request": request_view,
|
|
146
|
+
"note": "request was not sent.",
|
|
147
|
+
}
|
|
148
|
+
if op.display_name != op.operation_id:
|
|
149
|
+
out["operation_id_raw"] = op.operation_id
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
resp = http_mod.get_request(
|
|
153
|
+
op.backend.method,
|
|
154
|
+
url,
|
|
155
|
+
params=query_params if query_params else None,
|
|
156
|
+
body=body,
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
user_agent=user_agent,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if _is_waf_block(resp.text):
|
|
162
|
+
raise NetworkError(
|
|
163
|
+
f"Backend WAF blocked {op.backend.method} {url}",
|
|
164
|
+
kind="waf_block",
|
|
165
|
+
hint=(
|
|
166
|
+
"Some APIG backends reject the bundled oed/x.y.z User-Agent. "
|
|
167
|
+
"Retry with `--user-agent 'Mozilla/5.0 ...'` or set "
|
|
168
|
+
"OED_USER_AGENT in the environment."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
out = {
|
|
173
|
+
"ok": 200 <= resp.status_code < 400,
|
|
174
|
+
"service": op.service_name,
|
|
175
|
+
"operation": op.display_name,
|
|
176
|
+
"method": op.backend.method,
|
|
177
|
+
"url": url,
|
|
178
|
+
"status": resp.status_code,
|
|
179
|
+
"response": _render_response(resp),
|
|
180
|
+
}
|
|
181
|
+
if include_request:
|
|
182
|
+
out["request"] = request_view
|
|
183
|
+
if op.display_name != op.operation_id:
|
|
184
|
+
out["operation_id_raw"] = op.operation_id
|
|
185
|
+
if unused:
|
|
186
|
+
out["unused_params"] = unused
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def describe_operation(op: Operation) -> dict[str, Any]:
|
|
191
|
+
"""Render a single :class:`Operation` for ``oed <service>`` listing."""
|
|
192
|
+
|
|
193
|
+
rendered, _missing = _fill_path(op.path, {})
|
|
194
|
+
out: dict[str, Any] = {
|
|
195
|
+
"operation_id": op.display_name,
|
|
196
|
+
"method": op.http_method,
|
|
197
|
+
"path": op.path,
|
|
198
|
+
"url": f"{op.base_url}{rendered}",
|
|
199
|
+
"backend_declared": (
|
|
200
|
+
f"{op.backend.scheme}://{op.backend.address}{op.backend.path}"
|
|
201
|
+
if op.backend.address
|
|
202
|
+
else None
|
|
203
|
+
),
|
|
204
|
+
"summary": op.summary,
|
|
205
|
+
"description": op.description,
|
|
206
|
+
"path_params": [p["name"] for p in op.path_params],
|
|
207
|
+
"query_params": [p["name"] for p in op.query_params],
|
|
208
|
+
"body_required": op.body_required,
|
|
209
|
+
}
|
|
210
|
+
if op.display_name != op.operation_id:
|
|
211
|
+
out["operation_id_raw"] = op.operation_id
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def describe_service(service, ops: list) -> dict[str, Any]:
|
|
216
|
+
"""Bundle service metadata + operations into one JSON document."""
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"service": {
|
|
220
|
+
"name": service.name,
|
|
221
|
+
"service_name": service.service_name,
|
|
222
|
+
"community": service.community,
|
|
223
|
+
"title": service.title,
|
|
224
|
+
"version": service.version,
|
|
225
|
+
"base_url": service.base_url,
|
|
226
|
+
},
|
|
227
|
+
"operations": [describe_operation(op) for op in ops],
|
|
228
|
+
"generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def describe_operation_help(op: Operation, service) -> dict[str, Any]:
|
|
233
|
+
"""Render ``oed <service> <operation> --help``: every per-parameter flag
|
|
234
|
+
plus a copy-pasteable usage example."""
|
|
235
|
+
|
|
236
|
+
rendered, _missing = _fill_path(op.path, {})
|
|
237
|
+
params: list[dict[str, Any]] = []
|
|
238
|
+
for p in op.parameters:
|
|
239
|
+
if p.get("in") not in {"query", "path"}:
|
|
240
|
+
continue
|
|
241
|
+
flag_stem = to_flag(p["name"])
|
|
242
|
+
schema = p.get("schema") or {}
|
|
243
|
+
entry: dict[str, Any] = {
|
|
244
|
+
"name": p["name"],
|
|
245
|
+
"in": p["in"],
|
|
246
|
+
"required": bool(p.get("required")),
|
|
247
|
+
"flag": f"--{flag_stem}",
|
|
248
|
+
"alt_flag": f"--{p['name']}",
|
|
249
|
+
"type": schema.get("type", "string"),
|
|
250
|
+
}
|
|
251
|
+
if p.get("description"):
|
|
252
|
+
entry["description"] = p["description"]
|
|
253
|
+
params.append(entry)
|
|
254
|
+
|
|
255
|
+
name = op.display_name
|
|
256
|
+
out: dict[str, Any] = {
|
|
257
|
+
"ok": True,
|
|
258
|
+
"help_for": name,
|
|
259
|
+
"service": {
|
|
260
|
+
"name": service.name,
|
|
261
|
+
"service_name": service.service_name,
|
|
262
|
+
"title": service.title,
|
|
263
|
+
},
|
|
264
|
+
"method": op.http_method,
|
|
265
|
+
"path": op.path,
|
|
266
|
+
"url": f"{op.base_url}{rendered}",
|
|
267
|
+
"summary": op.summary,
|
|
268
|
+
"description": op.description,
|
|
269
|
+
"body_required": op.body_required,
|
|
270
|
+
"parameters": params,
|
|
271
|
+
"usage": _usage_example(op),
|
|
272
|
+
"examples": _usage_examples(op),
|
|
273
|
+
}
|
|
274
|
+
if name != op.operation_id:
|
|
275
|
+
out["operation_id_raw"] = op.operation_id
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _usage_example(op: Operation) -> str:
|
|
280
|
+
"""Single-line copy-pasteable invocation string."""
|
|
281
|
+
|
|
282
|
+
parts = [f"oed <service> {op.display_name}"]
|
|
283
|
+
for p in op.parameters:
|
|
284
|
+
if p.get("in") not in {"query", "path"}:
|
|
285
|
+
continue
|
|
286
|
+
flag = f"--{to_flag(p['name'])} <value>"
|
|
287
|
+
parts.append(flag if p.get("required") else f"[{flag}]")
|
|
288
|
+
if op.body_required:
|
|
289
|
+
parts.append("--json '{...}'")
|
|
290
|
+
parts.append("[--dry-run]")
|
|
291
|
+
return " ".join(parts)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _usage_examples(op: Operation) -> list[str]:
|
|
295
|
+
"""A couple of ready-to-paste example commands."""
|
|
296
|
+
|
|
297
|
+
name = op.display_name
|
|
298
|
+
examples: list[str] = []
|
|
299
|
+
required = [
|
|
300
|
+
p
|
|
301
|
+
for p in op.parameters
|
|
302
|
+
if p.get("in") in {"query", "path"} and p.get("required")
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
if required:
|
|
306
|
+
cmd = f"oed <service> {name}"
|
|
307
|
+
cmd += "".join(f" --{to_flag(p['name'])} <value>" for p in required)
|
|
308
|
+
cmd += " [--dry-run]"
|
|
309
|
+
examples.append(cmd)
|
|
310
|
+
examples.append(f"oed <service> {name} --params '{name}_PARAMS_JSON'")
|
|
311
|
+
return examples
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
__all__ = [
|
|
315
|
+
"call_operation",
|
|
316
|
+
"describe_operation",
|
|
317
|
+
"describe_operation_help",
|
|
318
|
+
"describe_service",
|
|
319
|
+
]
|