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/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
+ ]