oed-cli 0.2.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 +473 -0
- oed_cli/errors.py +45 -0
- oed_cli/http.py +150 -0
- oed_cli/invoke.py +305 -0
- oed_cli/main.py +409 -0
- oed_cli/py.typed +0 -0
- oed_cli-0.2.0.dist-info/METADATA +373 -0
- oed_cli-0.2.0.dist-info/RECORD +16 -0
- oed_cli-0.2.0.dist-info/WHEEL +5 -0
- oed_cli-0.2.0.dist-info/entry_points.txt +2 -0
- oed_cli-0.2.0.dist-info/licenses/LICENSE +17 -0
- oed_cli-0.2.0.dist-info/top_level.txt +1 -0
oed_cli/invoke.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
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. We always build the
|
|
13
|
+
runtime URL as ``RUNTIME_GATEWAY + op.path``, where ``op.path`` is the
|
|
14
|
+
OpenAPI ``paths`` key (e.g. ``/cve-security-notice-server/securitynotice/getByCveId``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as _dt
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from . import http as http_mod
|
|
25
|
+
from .dynamic import RUNTIME_GATEWAY, Operation, coerce_param_types, to_flag
|
|
26
|
+
from .errors import NetworkError, UserError
|
|
27
|
+
from .http import _is_waf_block
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fill_path(template: str, params: dict[str, Any]) -> tuple[str, list[str]]:
|
|
31
|
+
"""Substitute ``{name}`` placeholders in ``template`` from ``params``.
|
|
32
|
+
|
|
33
|
+
Returns the rendered path and the list of placeholder names that were
|
|
34
|
+
not provided, so the caller can raise a precise error.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
missing: list[str] = []
|
|
38
|
+
parts: list[str] = []
|
|
39
|
+
rest = template
|
|
40
|
+
while True:
|
|
41
|
+
head, sep, chunk = rest.partition("{")
|
|
42
|
+
if not sep:
|
|
43
|
+
parts.append(head)
|
|
44
|
+
break
|
|
45
|
+
name_end = chunk.find("}")
|
|
46
|
+
if name_end == -1:
|
|
47
|
+
parts.append(head + sep + chunk)
|
|
48
|
+
break
|
|
49
|
+
name = chunk[:name_end]
|
|
50
|
+
parts.append(head)
|
|
51
|
+
if name in params:
|
|
52
|
+
parts.append(str(params[name]))
|
|
53
|
+
else:
|
|
54
|
+
missing.append(name)
|
|
55
|
+
parts.append("{" + name + "}")
|
|
56
|
+
rest = chunk[name_end + 1 :]
|
|
57
|
+
return "".join(parts), missing
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _select_params(
|
|
61
|
+
op: Operation, raw: dict[str, Any] | None
|
|
62
|
+
) -> tuple[dict, dict, list[str]]:
|
|
63
|
+
"""Split ``raw`` into (path_params, query_params, unused_keys)."""
|
|
64
|
+
|
|
65
|
+
raw = raw or {}
|
|
66
|
+
path_names = {p["name"] for p in op.path_params}
|
|
67
|
+
query_names = {p.get("name") for p in op.query_params}
|
|
68
|
+
declared = path_names | query_names
|
|
69
|
+
|
|
70
|
+
path_params = {k: raw[k] for k in path_names if k in raw}
|
|
71
|
+
query_params = {k: raw[k] for k in raw if k in query_names and k not in path_params}
|
|
72
|
+
unused = sorted(k for k in raw if k not in declared)
|
|
73
|
+
return path_params, query_params, unused
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_response(resp: httpx.Response) -> Any:
|
|
77
|
+
"""Parse the JSON body of ``resp``, returning a wrapped string for non-JSON."""
|
|
78
|
+
|
|
79
|
+
if not resp.content:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return resp.json()
|
|
83
|
+
except Exception:
|
|
84
|
+
return {
|
|
85
|
+
"_non_json_body": resp.text[:8192],
|
|
86
|
+
"_content_type": resp.headers.get("content-type", ""),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def call_operation(
|
|
91
|
+
op: Operation,
|
|
92
|
+
*,
|
|
93
|
+
params: dict[str, Any] | None = None,
|
|
94
|
+
body: Any = None,
|
|
95
|
+
dry_run: bool = False,
|
|
96
|
+
timeout: float = 30.0,
|
|
97
|
+
include_request: bool = False,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Invoke ``op`` and return a structured JSON dict suitable for stdout.
|
|
100
|
+
|
|
101
|
+
``ok`` is ``True`` for any 2xx/3xx response. Non-success still surfaces
|
|
102
|
+
the body and status under ``response`` / ``status``; the caller is
|
|
103
|
+
responsible for the exit code.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
path_params, query_params, unused = _select_params(op, params)
|
|
107
|
+
query_params = coerce_param_types(op, query_params)
|
|
108
|
+
|
|
109
|
+
filled_path, missing = _fill_path(op.path, path_params)
|
|
110
|
+
if missing:
|
|
111
|
+
raise UserError(
|
|
112
|
+
f"missing path params: {missing}",
|
|
113
|
+
kind="missing_path_param",
|
|
114
|
+
hint=f"Provide them via --params: {', '.join(missing)}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
query_params = {k: v for k, v in query_params.items() if v is not None}
|
|
118
|
+
url = f"{RUNTIME_GATEWAY}{filled_path}"
|
|
119
|
+
|
|
120
|
+
request_view: dict[str, Any] = {
|
|
121
|
+
"method": op.backend.method,
|
|
122
|
+
"url": url,
|
|
123
|
+
"query": query_params,
|
|
124
|
+
"headers": {"Content-Type": "application/json"} if body is not None else {},
|
|
125
|
+
"body": body,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if dry_run:
|
|
129
|
+
out = {
|
|
130
|
+
"ok": True,
|
|
131
|
+
"dry_run": True,
|
|
132
|
+
"service": op.service_name,
|
|
133
|
+
"operation": op.display_name,
|
|
134
|
+
"method": op.backend.method,
|
|
135
|
+
"url": url,
|
|
136
|
+
"request": request_view,
|
|
137
|
+
"note": "request was not sent.",
|
|
138
|
+
}
|
|
139
|
+
if op.display_name != op.operation_id:
|
|
140
|
+
out["operation_id_raw"] = op.operation_id
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
resp = http_mod.get_request(
|
|
144
|
+
op.backend.method,
|
|
145
|
+
url,
|
|
146
|
+
params=query_params if query_params else None,
|
|
147
|
+
body=body,
|
|
148
|
+
timeout=timeout,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if _is_waf_block(resp.text):
|
|
152
|
+
raise NetworkError(
|
|
153
|
+
f"Backend WAF blocked {op.backend.method} {url}",
|
|
154
|
+
kind="waf_block",
|
|
155
|
+
hint="Try OED_EXTRA_HEADERS_JSON env var to send custom headers.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
out = {
|
|
159
|
+
"ok": 200 <= resp.status_code < 400,
|
|
160
|
+
"service": op.service_name,
|
|
161
|
+
"operation": op.display_name,
|
|
162
|
+
"method": op.backend.method,
|
|
163
|
+
"url": url,
|
|
164
|
+
"status": resp.status_code,
|
|
165
|
+
"response": _render_response(resp),
|
|
166
|
+
}
|
|
167
|
+
if include_request:
|
|
168
|
+
out["request"] = request_view
|
|
169
|
+
if op.display_name != op.operation_id:
|
|
170
|
+
out["operation_id_raw"] = op.operation_id
|
|
171
|
+
if unused:
|
|
172
|
+
out["unused_params"] = unused
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def describe_operation(op: Operation) -> dict[str, Any]:
|
|
177
|
+
"""Render a single :class:`Operation` for ``oed <service>`` listing."""
|
|
178
|
+
|
|
179
|
+
rendered, _missing = _fill_path(op.path, {})
|
|
180
|
+
out: dict[str, Any] = {
|
|
181
|
+
"operation_id": op.display_name,
|
|
182
|
+
"method": op.http_method,
|
|
183
|
+
"path": op.path,
|
|
184
|
+
"url": f"{RUNTIME_GATEWAY}{rendered}",
|
|
185
|
+
"backend_declared": (
|
|
186
|
+
f"{op.backend.scheme}://{op.backend.address}{op.backend.path}"
|
|
187
|
+
if op.backend.address
|
|
188
|
+
else None
|
|
189
|
+
),
|
|
190
|
+
"summary": op.summary,
|
|
191
|
+
"description": op.description,
|
|
192
|
+
"path_params": [p["name"] for p in op.path_params],
|
|
193
|
+
"query_params": [p["name"] for p in op.query_params],
|
|
194
|
+
"body_required": op.body_required,
|
|
195
|
+
}
|
|
196
|
+
if op.display_name != op.operation_id:
|
|
197
|
+
out["operation_id_raw"] = op.operation_id
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def describe_service(service, ops: list) -> dict[str, Any]:
|
|
202
|
+
"""Bundle service metadata + operations into one JSON document."""
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"service": {
|
|
206
|
+
"name": service.name,
|
|
207
|
+
"service_name": service.service_name,
|
|
208
|
+
"community": service.community,
|
|
209
|
+
"title": service.title,
|
|
210
|
+
"version": service.version,
|
|
211
|
+
"base_url": service.base_url,
|
|
212
|
+
},
|
|
213
|
+
"operations": [describe_operation(op) for op in ops],
|
|
214
|
+
"generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def describe_operation_help(op: Operation, service) -> dict[str, Any]:
|
|
219
|
+
"""Render ``oed <service> <operation> --help``: every per-parameter flag
|
|
220
|
+
plus a copy-pasteable usage example."""
|
|
221
|
+
|
|
222
|
+
rendered, _missing = _fill_path(op.path, {})
|
|
223
|
+
params: list[dict[str, Any]] = []
|
|
224
|
+
for p in op.parameters:
|
|
225
|
+
if p.get("in") not in {"query", "path"}:
|
|
226
|
+
continue
|
|
227
|
+
flag_stem = to_flag(p["name"])
|
|
228
|
+
schema = p.get("schema") or {}
|
|
229
|
+
entry: dict[str, Any] = {
|
|
230
|
+
"name": p["name"],
|
|
231
|
+
"in": p["in"],
|
|
232
|
+
"required": bool(p.get("required")),
|
|
233
|
+
"flag": f"--{flag_stem}",
|
|
234
|
+
"alt_flag": f"--{p['name']}",
|
|
235
|
+
"type": schema.get("type", "string"),
|
|
236
|
+
}
|
|
237
|
+
if p.get("description"):
|
|
238
|
+
entry["description"] = p["description"]
|
|
239
|
+
params.append(entry)
|
|
240
|
+
|
|
241
|
+
name = op.display_name
|
|
242
|
+
out: dict[str, Any] = {
|
|
243
|
+
"ok": True,
|
|
244
|
+
"help_for": name,
|
|
245
|
+
"service": {
|
|
246
|
+
"name": service.name,
|
|
247
|
+
"service_name": service.service_name,
|
|
248
|
+
"title": service.title,
|
|
249
|
+
},
|
|
250
|
+
"method": op.http_method,
|
|
251
|
+
"path": op.path,
|
|
252
|
+
"url": f"{RUNTIME_GATEWAY}{rendered}",
|
|
253
|
+
"summary": op.summary,
|
|
254
|
+
"description": op.description,
|
|
255
|
+
"body_required": op.body_required,
|
|
256
|
+
"parameters": params,
|
|
257
|
+
"usage": _usage_example(op),
|
|
258
|
+
"examples": _usage_examples(op),
|
|
259
|
+
}
|
|
260
|
+
if name != op.operation_id:
|
|
261
|
+
out["operation_id_raw"] = op.operation_id
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _usage_example(op: Operation) -> str:
|
|
266
|
+
"""Single-line copy-pasteable invocation string."""
|
|
267
|
+
|
|
268
|
+
parts = [f"oed <service> {op.display_name}"]
|
|
269
|
+
for p in op.parameters:
|
|
270
|
+
if p.get("in") not in {"query", "path"}:
|
|
271
|
+
continue
|
|
272
|
+
flag = f"--{to_flag(p['name'])} <value>"
|
|
273
|
+
parts.append(flag if p.get("required") else f"[{flag}]")
|
|
274
|
+
if op.body_required:
|
|
275
|
+
parts.append("--json '{...}'")
|
|
276
|
+
parts.append("[--dry-run]")
|
|
277
|
+
return " ".join(parts)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _usage_examples(op: Operation) -> list[str]:
|
|
281
|
+
"""A couple of ready-to-paste example commands."""
|
|
282
|
+
|
|
283
|
+
name = op.display_name
|
|
284
|
+
examples: list[str] = []
|
|
285
|
+
required = [
|
|
286
|
+
p
|
|
287
|
+
for p in op.parameters
|
|
288
|
+
if p.get("in") in {"query", "path"} and p.get("required")
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
if required:
|
|
292
|
+
cmd = f"oed <service> {name}"
|
|
293
|
+
cmd += "".join(f" --{to_flag(p['name'])} <value>" for p in required)
|
|
294
|
+
cmd += " [--dry-run]"
|
|
295
|
+
examples.append(cmd)
|
|
296
|
+
examples.append(f"oed <service> {name} --params '{name}_PARAMS_JSON'")
|
|
297
|
+
return examples
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__ = [
|
|
301
|
+
"call_operation",
|
|
302
|
+
"describe_operation",
|
|
303
|
+
"describe_operation_help",
|
|
304
|
+
"describe_service",
|
|
305
|
+
]
|