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