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/dynamic.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Dynamic OpenAPI → operation-table mapping.
|
|
2
|
+
|
|
3
|
+
Each operation in an openEuler-gateway OpenAPI doc lives under
|
|
4
|
+
``spec.paths[path][verb]`` with an optional
|
|
5
|
+
``x-apigateway-backend.httpEndpoints`` block describing the upstream
|
|
6
|
+
backend (scheme/address/path/method). :func:`collect_operations` walks
|
|
7
|
+
the spec and exposes each (path, verb) pair as a :class:`Operation` so
|
|
8
|
+
the dispatcher can build URLs without hand-written client code.
|
|
9
|
+
|
|
10
|
+
URL construction:
|
|
11
|
+
|
|
12
|
+
The ``x-apigateway-backend.httpEndpoints.address`` field often points to
|
|
13
|
+
a staging / test backend (e.g. ``cvesa.test.osinfra.cn``) that the
|
|
14
|
+
gateway's CloudWAF blocks — even with browser-style headers. The
|
|
15
|
+
production gateway at ``https://apig.osinfra.cn`` proxies every
|
|
16
|
+
service reliably, so :mod:`oed_cli.invoke` always builds the runtime
|
|
17
|
+
URL as ``RUNTIME_GATEWAY + spec.paths[key]``. The backend block is
|
|
18
|
+
parsed for diagnostics (method, scheme) only and never trusted for the
|
|
19
|
+
host.
|
|
20
|
+
|
|
21
|
+
CLI surface:
|
|
22
|
+
|
|
23
|
+
Each declared ``query`` / ``path`` parameter on an operation is exposed
|
|
24
|
+
as its own ``--<kebab-case>`` flag by :func:`to_flag` / :func:`param_flag_index`,
|
|
25
|
+
so users can run ``oed <service> <op> --cve-id CVE-2024-1234`` instead
|
|
26
|
+
of stuffing JSON into ``--params``. The legacy ``--params '{...}'``
|
|
27
|
+
form is preserved as an escape hatch and is overridden by per-param
|
|
28
|
+
flags when both are present.
|
|
29
|
+
|
|
30
|
+
The most common backend block we still see in specs:
|
|
31
|
+
|
|
32
|
+
"x-apigateway-backend": {
|
|
33
|
+
"type": "HTTP",
|
|
34
|
+
"httpEndpoints": {
|
|
35
|
+
"address": "software-pkg.openeuler.org",
|
|
36
|
+
"scheme": "https",
|
|
37
|
+
"method": "GET",
|
|
38
|
+
"path": "/api/v1/cla",
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Specs that omit this block still work — we just lose the explicit
|
|
43
|
+
method/scheme hints and fall back to ``GET``/``https``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import time
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from .discovery import (
|
|
57
|
+
DEFAULT_GATEWAY,
|
|
58
|
+
ServiceMeta,
|
|
59
|
+
current_community,
|
|
60
|
+
fetch_discovery,
|
|
61
|
+
)
|
|
62
|
+
from .errors import NotFoundError, UserError
|
|
63
|
+
from .http import request_json
|
|
64
|
+
|
|
65
|
+
SPEC_CACHE_TTL_SECONDS = 600 # 10 min, mirrors the discovery feed TTL
|
|
66
|
+
SPEC_URL_TEMPLATE = f"{DEFAULT_GATEWAY}/discovery/apis/{{community}}/{{service_name}}"
|
|
67
|
+
|
|
68
|
+
# Runtime gateway — every dynamic call goes through here, regardless of
|
|
69
|
+
# what ``x-apigateway-backend.httpEndpoints.address`` says. Specs often
|
|
70
|
+
# point to staging hosts (``*.test.osinfra.cn``) whose CloudWAF blocks
|
|
71
|
+
# browser-style traffic, so we route via the production gateway instead.
|
|
72
|
+
RUNTIME_GATEWAY = "https://apig.osinfra.cn"
|
|
73
|
+
|
|
74
|
+
_HTTP_VERBS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
75
|
+
|
|
76
|
+
# Flag-name derivation -------------------------------------------------- #
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_flag(name: str) -> str:
|
|
80
|
+
"""Convert a spec parameter name to a kebab-case CLI flag stem.
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
``cveId`` → ``cve-id``
|
|
84
|
+
``pageNum`` → ``page-num``
|
|
85
|
+
``count_per_page`` → ``count-per-page``
|
|
86
|
+
``id`` → ``id``
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", name)
|
|
90
|
+
s = s.replace("_", "-")
|
|
91
|
+
return s.lower()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def param_flag_index(op: Operation) -> dict[str, dict[str, Any]]:
|
|
95
|
+
"""Map CLI flag stems to declared query / path parameter defs.
|
|
96
|
+
|
|
97
|
+
Each declared parameter is registered twice — once under its
|
|
98
|
+
kebab-case stem (``--cve-id``) and once under the raw spec name
|
|
99
|
+
(``--cveId``) — so users can pick whichever form reads better.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
out: dict[str, dict[str, Any]] = {}
|
|
103
|
+
for p in op.parameters:
|
|
104
|
+
if p.get("in") not in {"query", "path"}:
|
|
105
|
+
continue
|
|
106
|
+
out[to_flag(p["name"])] = p
|
|
107
|
+
out[p["name"]] = p
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Backend:
|
|
113
|
+
"""The real upstream service backing one OpenAPI operation."""
|
|
114
|
+
|
|
115
|
+
scheme: str
|
|
116
|
+
address: str
|
|
117
|
+
path: str
|
|
118
|
+
method: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Operation:
|
|
123
|
+
"""One callable API operation, derived from a (path, verb) OpenAPI pair."""
|
|
124
|
+
|
|
125
|
+
service_name: str
|
|
126
|
+
path: str
|
|
127
|
+
http_method: str
|
|
128
|
+
backend: Backend
|
|
129
|
+
summary: str = ""
|
|
130
|
+
description: str = ""
|
|
131
|
+
parameters: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
132
|
+
body_required: bool = False
|
|
133
|
+
operation_id: str = ""
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_openapi(
|
|
137
|
+
cls,
|
|
138
|
+
*,
|
|
139
|
+
service_name: str,
|
|
140
|
+
path: str,
|
|
141
|
+
verb: str,
|
|
142
|
+
op: dict[str, Any],
|
|
143
|
+
backend: Backend,
|
|
144
|
+
) -> Operation:
|
|
145
|
+
return cls(
|
|
146
|
+
service_name=service_name,
|
|
147
|
+
path=path,
|
|
148
|
+
http_method=verb.upper(),
|
|
149
|
+
backend=backend,
|
|
150
|
+
summary=op.get("summary", ""),
|
|
151
|
+
description=op.get("description", ""),
|
|
152
|
+
parameters=tuple(op.get("parameters", []) or ()),
|
|
153
|
+
body_required=bool(op.get("requestBody", {}).get("required")),
|
|
154
|
+
operation_id=op.get("operationId") or f"{verb.upper()} {path}",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def path_params(self) -> list[dict[str, Any]]:
|
|
159
|
+
return [p for p in self.parameters if p.get("in") == "path"]
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def query_params(self) -> list[dict[str, Any]]:
|
|
163
|
+
return [p for p in self.parameters if p.get("in") == "query"]
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def display_name(self) -> str:
|
|
167
|
+
"""User-facing operation name. Strips the ``API_`` prefix that
|
|
168
|
+
Huawei APIG auto-appends to every operationId on services like
|
|
169
|
+
``software-package-server`` — see ``operations_table`` for the
|
|
170
|
+
lookup alias that keeps the raw form working too."""
|
|
171
|
+
|
|
172
|
+
if self.operation_id.startswith("API_") and len(self.operation_id) > 4:
|
|
173
|
+
return self.operation_id[4:]
|
|
174
|
+
return self.operation_id
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_backend(op: dict[str, Any]) -> Backend | None:
|
|
178
|
+
"""Return the :class:`Backend` for an operation, or ``None`` if not HTTP-typed."""
|
|
179
|
+
|
|
180
|
+
raw = op.get("x-apigateway-backend")
|
|
181
|
+
if not isinstance(raw, dict) or raw.get("type") != "HTTP":
|
|
182
|
+
return None
|
|
183
|
+
eps = raw.get("httpEndpoints") or {}
|
|
184
|
+
return Backend(
|
|
185
|
+
scheme=eps.get("scheme", "https"),
|
|
186
|
+
address=eps.get("address", ""),
|
|
187
|
+
path=eps.get("path", "/"),
|
|
188
|
+
method=(eps.get("method") or "GET").upper(),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def collect_operations(spec: dict[str, Any], service_name: str) -> list[Operation]:
|
|
193
|
+
"""Walk ``spec.paths`` and return every HTTP-typed operation."""
|
|
194
|
+
|
|
195
|
+
out: list[Operation] = []
|
|
196
|
+
paths = spec.get("paths") or {}
|
|
197
|
+
for path, item in paths.items():
|
|
198
|
+
if not isinstance(item, dict):
|
|
199
|
+
continue
|
|
200
|
+
for verb, op in item.items():
|
|
201
|
+
if verb.lower() not in _HTTP_VERBS or not isinstance(op, dict):
|
|
202
|
+
continue
|
|
203
|
+
backend = _parse_backend(op)
|
|
204
|
+
if backend is None:
|
|
205
|
+
continue
|
|
206
|
+
out.append(
|
|
207
|
+
Operation.from_openapi(
|
|
208
|
+
service_name=service_name, path=path, verb=verb, op=op, backend=backend
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def operations_table(spec: dict[str, Any], service_name: str) -> dict[str, Operation]:
|
|
215
|
+
"""Build a name → :class:`Operation` lookup. Each operationId is the primary
|
|
216
|
+
key; secondary aliases are ``"<VERB> <path>"`` (e.g. ``"GET /v1/cla"``)
|
|
217
|
+
and — for specs whose operationIds carry an APIG-generated ``API_``
|
|
218
|
+
prefix — the prefix-stripped form (``API_listFoo`` → ``listFoo``)."""
|
|
219
|
+
|
|
220
|
+
table: dict[str, Operation] = {}
|
|
221
|
+
for op in collect_operations(spec, service_name):
|
|
222
|
+
primary = op.operation_id
|
|
223
|
+
table[primary] = op
|
|
224
|
+
table[f"{op.http_method} {op.path}"] = op
|
|
225
|
+
stripped = op.display_name
|
|
226
|
+
if stripped != primary and stripped not in table:
|
|
227
|
+
table[stripped] = op
|
|
228
|
+
return table
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def resolve_operation(table: dict[str, Operation], name: str) -> Operation:
|
|
232
|
+
"""Look up an operation by ``name`` (case-insensitive), with helpful errors."""
|
|
233
|
+
|
|
234
|
+
if name in table:
|
|
235
|
+
return table[name]
|
|
236
|
+
lowered = name.lower()
|
|
237
|
+
for key, op in table.items():
|
|
238
|
+
if key.lower() == lowered:
|
|
239
|
+
return op
|
|
240
|
+
raise NotFoundError(
|
|
241
|
+
f"no operation matches '{name}'",
|
|
242
|
+
kind="method_not_found",
|
|
243
|
+
hint="Run `oed <service>` to list every operationId for this service.",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_json_arg(blob: str | None, *, flag: str) -> dict[str, Any] | None:
|
|
248
|
+
"""Parse a JSON flag with a precise error pointing at the flag name."""
|
|
249
|
+
|
|
250
|
+
if blob is None or blob == "":
|
|
251
|
+
return None
|
|
252
|
+
try:
|
|
253
|
+
obj = json.loads(blob)
|
|
254
|
+
except json.JSONDecodeError as exc:
|
|
255
|
+
raise UserError(
|
|
256
|
+
f"--{flag} is not valid JSON: {exc.msg} (line {exc.lineno}, col {exc.colno})",
|
|
257
|
+
kind="invalid_json",
|
|
258
|
+
) from exc
|
|
259
|
+
if not isinstance(obj, dict):
|
|
260
|
+
raise UserError(
|
|
261
|
+
f"--{flag} must decode to a JSON object, got {type(obj).__name__}",
|
|
262
|
+
kind="invalid_json",
|
|
263
|
+
)
|
|
264
|
+
return obj
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def coerce_param_types(op: Operation, params: dict[str, Any]) -> dict[str, Any]:
|
|
268
|
+
"""Cast well-known string params to int/float based on the OpenAPI schema.
|
|
269
|
+
|
|
270
|
+
openEuler specs declare ``schema.type: integer`` for fields like
|
|
271
|
+
``page_num``/``count_per_page``; many callers pass them as strings.
|
|
272
|
+
Without coercion the backend rejects them. We only convert ints the
|
|
273
|
+
spec actually declares, so we never mis-cast user data.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
out: dict[str, Any] = {}
|
|
277
|
+
declared_ints = {
|
|
278
|
+
p["name"]
|
|
279
|
+
for p in op.parameters
|
|
280
|
+
if p.get("schema", {}).get("type") == "integer" and p.get("name") in params
|
|
281
|
+
}
|
|
282
|
+
declared_numbers = {
|
|
283
|
+
p["name"]
|
|
284
|
+
for p in op.parameters
|
|
285
|
+
if p.get("schema", {}).get("type") == "number" and p.get("name") in params
|
|
286
|
+
}
|
|
287
|
+
for k, v in params.items():
|
|
288
|
+
if k in declared_ints and isinstance(v, str) and v.lstrip("-").isdigit():
|
|
289
|
+
out[k] = int(v)
|
|
290
|
+
elif k in declared_numbers and isinstance(v, str):
|
|
291
|
+
try:
|
|
292
|
+
out[k] = float(v)
|
|
293
|
+
except ValueError:
|
|
294
|
+
out[k] = v
|
|
295
|
+
else:
|
|
296
|
+
out[k] = v
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def coerce_flag_value(param_def: dict[str, Any], value: str) -> Any:
|
|
301
|
+
"""Coerce one CLI string flag to the spec-declared type.
|
|
302
|
+
|
|
303
|
+
CLI flags arrive as strings (``--page-num 3`` → ``"3"``), but the
|
|
304
|
+
OpenAPI schema often declares ``integer``. Callers can also pass
|
|
305
|
+
already-typed values via ``--params '{...}'``; those bypass this
|
|
306
|
+
helper and go through :func:`coerce_param_types` instead.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
schema_type = (param_def.get("schema") or {}).get("type", "string")
|
|
310
|
+
if schema_type == "integer":
|
|
311
|
+
stripped = value.lstrip("-")
|
|
312
|
+
if stripped.isdigit():
|
|
313
|
+
return int(value)
|
|
314
|
+
return value
|
|
315
|
+
if schema_type == "number":
|
|
316
|
+
try:
|
|
317
|
+
return float(value)
|
|
318
|
+
except ValueError:
|
|
319
|
+
return value
|
|
320
|
+
if schema_type == "boolean":
|
|
321
|
+
lowered = value.lower()
|
|
322
|
+
if lowered in {"true", "1", "yes", "on"}:
|
|
323
|
+
return True
|
|
324
|
+
if lowered in {"false", "0", "no", "off"}:
|
|
325
|
+
return False
|
|
326
|
+
return value
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
# Local spec cache (per-service file, mirrors discovery.py layout)
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _spec_cache_path(community: str, service_name: str) -> Path:
|
|
335
|
+
from .discovery import _cache_dir # reuse the XDG resolver
|
|
336
|
+
|
|
337
|
+
return _cache_dir() / "specs" / community / f"{service_name}.json"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _read_spec_cache(path: Path) -> dict[str, Any] | None:
|
|
341
|
+
if not path.is_file():
|
|
342
|
+
return None
|
|
343
|
+
try:
|
|
344
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
345
|
+
except (OSError, json.JSONDecodeError):
|
|
346
|
+
return None
|
|
347
|
+
age = time.time() - float(raw.get("__oed_fetched_at", 0))
|
|
348
|
+
if age >= SPEC_CACHE_TTL_SECONDS:
|
|
349
|
+
return None
|
|
350
|
+
spec = raw.get("spec")
|
|
351
|
+
return spec if isinstance(spec, dict) else None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _write_spec_cache(path: Path, spec: dict[str, Any]) -> None:
|
|
355
|
+
payload = {
|
|
356
|
+
"__oed_fetched_at": time.time(),
|
|
357
|
+
"spec": spec,
|
|
358
|
+
}
|
|
359
|
+
try:
|
|
360
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
362
|
+
except OSError:
|
|
363
|
+
return # best-effort
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def fetch_service_spec(
|
|
367
|
+
service: ServiceMeta, *, force_refresh: bool = False
|
|
368
|
+
) -> dict[str, Any]:
|
|
369
|
+
"""Load one service's OpenAPI, using a file cache when fresh."""
|
|
370
|
+
|
|
371
|
+
if not force_refresh:
|
|
372
|
+
cached = _read_spec_cache(_spec_cache_path(service.community, service.service_name))
|
|
373
|
+
if cached is not None:
|
|
374
|
+
return cached
|
|
375
|
+
|
|
376
|
+
url = SPEC_URL_TEMPLATE.format(community=service.community, service_name=service.service_name)
|
|
377
|
+
spec = request_json("GET", url)
|
|
378
|
+
if not isinstance(spec, dict) or "openapi" not in spec:
|
|
379
|
+
raise NotFoundError(
|
|
380
|
+
f"GET {url} did not return an OpenAPI document",
|
|
381
|
+
kind="spec_missing",
|
|
382
|
+
hint="The discovery feed lists this service but its OpenAPI spec is not available.",
|
|
383
|
+
)
|
|
384
|
+
_write_spec_cache(_spec_cache_path(service.community, service.service_name), spec)
|
|
385
|
+
return spec
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def resolve_service(community: str | None = None, force_refresh: bool = False) -> ServiceMeta:
|
|
389
|
+
"""Find the service named by the ``OED_SERVICE`` env var (used in tests).
|
|
390
|
+
|
|
391
|
+
Real CLI dispatch uses :func:`resolve_service_by_name`. This helper exists
|
|
392
|
+
so that scripts / tests can still drive ``invoke.call_operation`` without
|
|
393
|
+
going through the full dispatch table.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
name = os.environ.get("OED_SERVICE")
|
|
397
|
+
if not name:
|
|
398
|
+
raise UserError(
|
|
399
|
+
"OED_SERVICE env var is not set; pass service_name explicitly.",
|
|
400
|
+
kind="missing_service",
|
|
401
|
+
)
|
|
402
|
+
return resolve_service_by_name(name, community=community, force_refresh=force_refresh)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def resolve_service_by_name(
|
|
406
|
+
service_name: str,
|
|
407
|
+
*,
|
|
408
|
+
community: str | None = None,
|
|
409
|
+
force_refresh: bool = False,
|
|
410
|
+
) -> ServiceMeta:
|
|
411
|
+
"""Locate a :class:`ServiceMeta` by ``service_name`` in the given/active community."""
|
|
412
|
+
|
|
413
|
+
feed = fetch_discovery(force_refresh=force_refresh)
|
|
414
|
+
target_community = community or current_community()
|
|
415
|
+
matches = [s for s in feed.services if s.service_name == service_name]
|
|
416
|
+
if not matches:
|
|
417
|
+
other = sorted({s.community for s in feed.services})
|
|
418
|
+
raise NotFoundError(
|
|
419
|
+
f"service '{service_name}' is not registered",
|
|
420
|
+
kind="service_not_found",
|
|
421
|
+
hint=(
|
|
422
|
+
f"Available communities: {other}. "
|
|
423
|
+
"Run `oed services` to list registered services."
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
if len(matches) == 1:
|
|
427
|
+
return matches[0]
|
|
428
|
+
for s in matches:
|
|
429
|
+
if s.community == target_community:
|
|
430
|
+
return s
|
|
431
|
+
other_communities = sorted({s.community for s in matches})
|
|
432
|
+
raise UserError(
|
|
433
|
+
f"service '{service_name}' is registered in multiple communities {other_communities}; "
|
|
434
|
+
f"set OED_COMMUNITY to pick one (current: {target_community}).",
|
|
435
|
+
kind="ambiguous_service",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def operations_for(
|
|
440
|
+
service_name: str,
|
|
441
|
+
*,
|
|
442
|
+
community: str | None = None,
|
|
443
|
+
force_refresh: bool = False,
|
|
444
|
+
) -> tuple[ServiceMeta, dict[str, Operation], dict[str, Any]]:
|
|
445
|
+
"""One-shot helper: resolve a service, fetch its spec, build the table.
|
|
446
|
+
|
|
447
|
+
Returns ``(service_meta, operations_table, raw_spec)`` so callers can
|
|
448
|
+
inspect both the structured view and the raw OpenAPI doc.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
service = resolve_service_by_name(
|
|
452
|
+
service_name, community=community, force_refresh=force_refresh
|
|
453
|
+
)
|
|
454
|
+
spec = fetch_service_spec(service, force_refresh=force_refresh)
|
|
455
|
+
return service, operations_table(spec, service.service_name), spec
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
__all__ = [
|
|
459
|
+
"Backend",
|
|
460
|
+
"Operation",
|
|
461
|
+
"RUNTIME_GATEWAY",
|
|
462
|
+
"collect_operations",
|
|
463
|
+
"coerce_flag_value",
|
|
464
|
+
"coerce_param_types",
|
|
465
|
+
"fetch_service_spec",
|
|
466
|
+
"operations_for",
|
|
467
|
+
"operations_table",
|
|
468
|
+
"param_flag_index",
|
|
469
|
+
"parse_json_arg",
|
|
470
|
+
"resolve_operation",
|
|
471
|
+
"resolve_service_by_name",
|
|
472
|
+
"to_flag",
|
|
473
|
+
]
|
oed_cli/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Exit codes and exception hierarchy for ``oed``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExitCode:
|
|
7
|
+
OK = 0
|
|
8
|
+
USER_ERROR = 1
|
|
9
|
+
NETWORK_ERROR = 2
|
|
10
|
+
UPSTREAM_ERROR = 3
|
|
11
|
+
NOT_FOUND = 4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OedError(Exception):
|
|
15
|
+
"""Base for all ``oed`` runtime errors."""
|
|
16
|
+
|
|
17
|
+
code: int = ExitCode.USER_ERROR
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, kind: str | None = None, hint: str | None = None) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.kind = kind or self.__class__.__name__
|
|
23
|
+
self.hint = hint
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
d: dict = {"ok": False, "code": self.code, "error": self.kind, "message": self.message}
|
|
27
|
+
if self.hint:
|
|
28
|
+
d["hint"] = self.hint
|
|
29
|
+
return d
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UserError(OedError):
|
|
33
|
+
code = ExitCode.USER_ERROR
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NetworkError(OedError):
|
|
37
|
+
code = ExitCode.NETWORK_ERROR
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UpstreamError(OedError):
|
|
41
|
+
code = ExitCode.UPSTREAM_ERROR
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotFoundError(OedError):
|
|
45
|
+
code = ExitCode.NOT_FOUND
|
oed_cli/http.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .errors import NetworkError, NotFoundError, UpstreamError
|
|
17
|
+
|
|
18
|
+
DEFAULT_GATEWAY = "https://api-gateway.osinfra.cn"
|
|
19
|
+
_TIMEOUT_SECONDS = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _headers(extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
23
|
+
h = {
|
|
24
|
+
"User-Agent": f"oed/{__version__} (+https://gitee.com/openeuler/oed-cli)",
|
|
25
|
+
"Accept": "application/json, text/plain, */*",
|
|
26
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
27
|
+
"Referer": f"{DEFAULT_GATEWAY}/",
|
|
28
|
+
}
|
|
29
|
+
if extra:
|
|
30
|
+
h.update(extra)
|
|
31
|
+
return h
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_waf_block(html: str) -> bool:
|
|
35
|
+
lowered = html[:512].lower()
|
|
36
|
+
return (
|
|
37
|
+
"<!doctype html" in lowered
|
|
38
|
+
and ("cloudwaf" in lowered or "访问被拦截" in html or "requestid" in lowered)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_json(url: str, *, params: dict | None = None, headers: dict | None = None) -> Any:
|
|
43
|
+
"""GET ``url`` and return parsed JSON.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
|
|
47
|
+
- :class:`NetworkError` (exit 2) for connectivity / WAF failures
|
|
48
|
+
- :class:`UpstreamError` (exit 3) for genuine 5xx / unexpected payloads
|
|
49
|
+
- :class:`NotFoundError` (exit 4) when the upstream reports an empty spec
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
return _decode(get_request("GET", url, params=params, headers=headers), spec_endpoint=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_request(
|
|
56
|
+
method: str,
|
|
57
|
+
url: str,
|
|
58
|
+
*,
|
|
59
|
+
params: dict | None = None,
|
|
60
|
+
body: Any = None,
|
|
61
|
+
headers: dict | None = None,
|
|
62
|
+
timeout: float = _TIMEOUT_SECONDS,
|
|
63
|
+
) -> httpx.Response:
|
|
64
|
+
"""Run an arbitrary HTTP request and return the raw :class:`httpx.Response`.
|
|
65
|
+
|
|
66
|
+
Adds the WAF-safe browser headers; auto-declares ``Content-Type: application/json``
|
|
67
|
+
when ``body`` is set and the caller has not overridden it. Surfaces
|
|
68
|
+
connectivity failures as :class:`NetworkError`; WAF blocks as
|
|
69
|
+
:class:`NetworkError` (kind ``waf_block``); 5xx as :class:`UpstreamError`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
method = method.upper()
|
|
73
|
+
extra = dict(headers or {})
|
|
74
|
+
if body is not None and not any(h.lower() == "content-type" for h in extra):
|
|
75
|
+
extra["Content-Type"] = "application/json"
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
79
|
+
return client.request(
|
|
80
|
+
method,
|
|
81
|
+
url,
|
|
82
|
+
params=params if params else None,
|
|
83
|
+
json=body if body is not None else None,
|
|
84
|
+
headers=_headers(extra),
|
|
85
|
+
)
|
|
86
|
+
except httpx.HTTPError as exc:
|
|
87
|
+
raise NetworkError(f"{method} {url} failed: {exc}", kind="network_error") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _decode(
|
|
91
|
+
resp: httpx.Response,
|
|
92
|
+
*,
|
|
93
|
+
spec_endpoint: bool = False,
|
|
94
|
+
) -> Any:
|
|
95
|
+
"""Validate ``resp`` and return parsed JSON.
|
|
96
|
+
|
|
97
|
+
``spec_endpoint=True`` treats an empty body as :class:`NotFoundError`
|
|
98
|
+
(used for spec discovery). For runtime service calls an empty body is
|
|
99
|
+
considered a successful empty payload (``null``).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
if resp.status_code >= 500:
|
|
103
|
+
raise UpstreamError(
|
|
104
|
+
f"{resp.request.method} {resp.url} returned {resp.status_code}",
|
|
105
|
+
kind="upstream_error",
|
|
106
|
+
hint="Check gateway status; retry shortly.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if _is_waf_block(resp.text):
|
|
110
|
+
raise NetworkError(
|
|
111
|
+
f"Gateway WAF blocked the request to {resp.url}",
|
|
112
|
+
kind="waf_block",
|
|
113
|
+
hint="This is unexpected — file an issue with the URL you tried.",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not resp.content:
|
|
117
|
+
if spec_endpoint:
|
|
118
|
+
raise NotFoundError(
|
|
119
|
+
f"{resp.request.method} {resp.url} "
|
|
120
|
+
f"returned an empty body (HTTP {resp.status_code})",
|
|
121
|
+
kind="spec_missing",
|
|
122
|
+
hint=(
|
|
123
|
+
"The discovery feed lists this service but its OpenAPI "
|
|
124
|
+
"spec has not been published yet."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return resp.json()
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
raise UpstreamError(
|
|
133
|
+
f"{resp.request.method} {resp.url} returned non-JSON body (HTTP {resp.status_code})",
|
|
134
|
+
kind="upstream_error",
|
|
135
|
+
) from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def request_json(
|
|
139
|
+
method: str,
|
|
140
|
+
url: str,
|
|
141
|
+
*,
|
|
142
|
+
params: dict | None = None,
|
|
143
|
+
body: Any = None,
|
|
144
|
+
headers: dict | None = None,
|
|
145
|
+
timeout: float = _TIMEOUT_SECONDS,
|
|
146
|
+
) -> Any:
|
|
147
|
+
"""Run a request and return parsed JSON (empty body → ``None``)."""
|
|
148
|
+
|
|
149
|
+
resp = get_request(method, url, params=params, body=body, headers=headers, timeout=timeout)
|
|
150
|
+
return _decode(resp)
|