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/dynamic.py ADDED
@@ -0,0 +1,523 @@
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. Each
15
+ service's runtime gateway is whatever its discovery feed entry says
16
+ (``ServiceMeta.base_url``); :func:`resolve_runtime_gateway` reads that
17
+ value, strips whitespace and a trailing ``/``, and returns it verbatim.
18
+ The runtime URL is therefore ``resolve_runtime_gateway(service) +
19
+ spec.paths[key]``; the backend block is parsed for diagnostics
20
+ (method, scheme) only and never trusted for the host. There is **no
21
+ fallback** — if the gateway hands back an empty string or the legacy
22
+ ``$APIG_GROUP_ENTRY_URL`` placeholder, the resulting URL will fail at
23
+ HTTP time and surface a clear error.
24
+
25
+ CLI surface:
26
+
27
+ Each declared ``query`` / ``path`` parameter on an operation is exposed
28
+ as its own ``--<kebab-case>`` flag by :func:`to_flag` / :func:`param_flag_index`,
29
+ so users can run ``oed <service> <op> --cve-id CVE-2024-1234`` instead
30
+ of stuffing JSON into ``--params``. The legacy ``--params '{...}'``
31
+ form is preserved as an escape hatch and is overridden by per-param
32
+ flags when both are present.
33
+
34
+ The most common backend block we still see in specs:
35
+
36
+ "x-apigateway-backend": {
37
+ "type": "HTTP",
38
+ "httpEndpoints": {
39
+ "address": "software-pkg.openeuler.org",
40
+ "scheme": "https",
41
+ "method": "GET",
42
+ "path": "/api/v1/cla",
43
+ }
44
+ }
45
+
46
+ Specs that omit this block still work — the operation is registered
47
+ with an empty ``address`` and its method/scheme taken from the OpenAPI
48
+ ``(path, verb)`` pair. Several openEuler services (``cve``,
49
+ ``pkgcontrib``, …) publish specs without it.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import os
56
+ import re
57
+ import time
58
+ from dataclasses import dataclass, field
59
+ from pathlib import Path
60
+ from typing import Any
61
+
62
+ from .discovery import (
63
+ DEFAULT_GATEWAY,
64
+ ServiceMeta,
65
+ current_community,
66
+ fetch_discovery,
67
+ )
68
+ from .errors import NotFoundError, UserError
69
+ from .http import request_json
70
+
71
+ SPEC_CACHE_TTL_SECONDS = 600 # 10 min, mirrors the discovery feed TTL
72
+ SPEC_URL_TEMPLATE = f"{DEFAULT_GATEWAY}/discovery/apis/{{community}}/{{service_name}}"
73
+
74
+ # Runtime base URL for every dynamic call. Each service's gateway host
75
+ # is read straight from the discovery feed (``ServiceMeta.base_url``) —
76
+ # no fallback constant, no environment override, no derivation from
77
+ # the OpenAPI spec. The gateway is the source of truth.
78
+ _HTTP_VERBS = {"get", "post", "put", "patch", "delete", "head", "options"}
79
+
80
+
81
+ def resolve_runtime_gateway(service: ServiceMeta) -> str:
82
+ """Return the runtime base URL for ``service``.
83
+
84
+ Reads ``service.base_url`` from the discovery feed, stripping
85
+ whitespace and a trailing ``/`` so concatenation with an OpenAPI
86
+ path (``/v1/...``) never produces ``//``. Returns the feed value
87
+ verbatim — if the gateway hands back an empty string or the legacy
88
+ ``$APIG_GROUP_ENTRY_URL`` placeholder, that string flows through
89
+ and the HTTP call fails loudly rather than being silently rewritten.
90
+ """
91
+
92
+ candidate = (service.base_url or "").strip()
93
+ return candidate.rstrip("/")
94
+
95
+ # Flag-name derivation -------------------------------------------------- #
96
+
97
+
98
+ def to_flag(name: str) -> str:
99
+ """Convert a spec parameter name to a kebab-case CLI flag stem.
100
+
101
+ Examples:
102
+ ``cveId`` → ``cve-id``
103
+ ``pageNum`` → ``page-num``
104
+ ``count_per_page`` → ``count-per-page``
105
+ ``id`` → ``id``
106
+ """
107
+
108
+ s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", name)
109
+ s = s.replace("_", "-")
110
+ return s.lower()
111
+
112
+
113
+ def param_flag_index(op: Operation) -> dict[str, dict[str, Any]]:
114
+ """Map CLI flag stems to declared query / path parameter defs.
115
+
116
+ Each declared parameter is registered twice — once under its
117
+ kebab-case stem (``--cve-id``) and once under the raw spec name
118
+ (``--cveId``) — so users can pick whichever form reads better.
119
+ """
120
+
121
+ out: dict[str, dict[str, Any]] = {}
122
+ for p in op.parameters:
123
+ if p.get("in") not in {"query", "path"}:
124
+ continue
125
+ out[to_flag(p["name"])] = p
126
+ out[p["name"]] = p
127
+ return out
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class Backend:
132
+ """The real upstream service backing one OpenAPI operation."""
133
+
134
+ scheme: str
135
+ address: str
136
+ path: str
137
+ method: str
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class Operation:
142
+ """One callable API operation, derived from a (path, verb) OpenAPI pair."""
143
+
144
+ service_name: str
145
+ path: str
146
+ http_method: str
147
+ backend: Backend
148
+ summary: str = ""
149
+ description: str = ""
150
+ parameters: tuple[dict[str, Any], ...] = field(default_factory=tuple)
151
+ body_required: bool = False
152
+ operation_id: str = ""
153
+ # Resolved runtime base URL — read from ``ServiceMeta.base_url`` via
154
+ # :func:`resolve_runtime_gateway`. Empty by default so callers that
155
+ # build Operations without a service still work; ``invoke.py`` reads
156
+ # this field to build every request URL.
157
+ base_url: str = ""
158
+
159
+ @classmethod
160
+ def from_openapi(
161
+ cls,
162
+ *,
163
+ service_name: str,
164
+ path: str,
165
+ verb: str,
166
+ op: dict[str, Any],
167
+ backend: Backend,
168
+ base_url: str = "",
169
+ ) -> Operation:
170
+ return cls(
171
+ service_name=service_name,
172
+ path=path,
173
+ http_method=verb.upper(),
174
+ backend=backend,
175
+ summary=op.get("summary", ""),
176
+ description=op.get("description", ""),
177
+ parameters=tuple(op.get("parameters", []) or ()),
178
+ body_required=bool(op.get("requestBody", {}).get("required")),
179
+ operation_id=op.get("operationId") or f"{verb.upper()} {path}",
180
+ base_url=base_url,
181
+ )
182
+
183
+ @property
184
+ def path_params(self) -> list[dict[str, Any]]:
185
+ return [p for p in self.parameters if p.get("in") == "path"]
186
+
187
+ @property
188
+ def query_params(self) -> list[dict[str, Any]]:
189
+ return [p for p in self.parameters if p.get("in") == "query"]
190
+
191
+ @property
192
+ def display_name(self) -> str:
193
+ """User-facing operation name. Strips the ``API_`` prefix that
194
+ Huawei APIG auto-appends to every operationId on services like
195
+ ``software-package-server`` — see ``operations_table`` for the
196
+ lookup alias that keeps the raw form working too."""
197
+
198
+ if self.operation_id.startswith("API_") and len(self.operation_id) > 4:
199
+ return self.operation_id[4:]
200
+ return self.operation_id
201
+
202
+
203
+ def _parse_backend(op: dict[str, Any], *, path: str, verb: str) -> Backend:
204
+ """Return the :class:`Backend` for an operation.
205
+
206
+ When the spec omits ``x-apigateway-backend`` (or types it as something
207
+ other than ``HTTP``) we synthesize one from the OpenAPI ``(path, verb)``
208
+ pair with an empty ``address``. The block is diagnostics-only — the
209
+ runtime host always comes from :func:`resolve_runtime_gateway` — so a
210
+ missing block must not make the operation disappear from the command
211
+ table. Several openEuler services (``cve``, ``pkgcontrib``, …) ship
212
+ specs without it.
213
+ """
214
+
215
+ raw = op.get("x-apigateway-backend")
216
+ if not isinstance(raw, dict) or raw.get("type") != "HTTP":
217
+ return Backend(scheme="https", address="", path=path, method=verb.upper())
218
+ eps = raw.get("httpEndpoints") or {}
219
+ return Backend(
220
+ scheme=eps.get("scheme", "https"),
221
+ address=eps.get("address", ""),
222
+ path=eps.get("path", "/"),
223
+ method=(eps.get("method") or verb).upper(),
224
+ )
225
+
226
+
227
+ def collect_operations(
228
+ spec: dict[str, Any], service_name: str, *, base_url: str = ""
229
+ ) -> list[Operation]:
230
+ """Walk ``spec.paths`` and return every operation.
231
+
232
+ ``base_url`` is the resolved runtime URL (output of
233
+ :func:`resolve_runtime_gateway`); it is baked into every returned
234
+ :class:`Operation` so :mod:`oed_cli.invoke` does not need to know
235
+ which service produced the operation.
236
+ """
237
+
238
+ out: list[Operation] = []
239
+ paths = spec.get("paths") or {}
240
+ for path, item in paths.items():
241
+ if not isinstance(item, dict):
242
+ continue
243
+ for verb, op in item.items():
244
+ if verb.lower() not in _HTTP_VERBS or not isinstance(op, dict):
245
+ continue
246
+ out.append(
247
+ Operation.from_openapi(
248
+ service_name=service_name,
249
+ path=path,
250
+ verb=verb,
251
+ op=op,
252
+ backend=_parse_backend(op, path=path, verb=verb),
253
+ base_url=base_url,
254
+ )
255
+ )
256
+ return out
257
+
258
+
259
+ def operations_table(
260
+ spec: dict[str, Any], service_name: str, *, base_url: str = ""
261
+ ) -> dict[str, Operation]:
262
+ """Build a name → :class:`Operation` lookup. Each operationId is the primary
263
+ key; secondary aliases are ``"<VERB> <path>"`` (e.g. ``"GET /v1/cla"``)
264
+ and — for specs whose operationIds carry an APIG-generated ``API_``
265
+ prefix — the prefix-stripped form (``API_listFoo`` → ``listFoo``)."""
266
+
267
+ table: dict[str, Operation] = {}
268
+ for op in collect_operations(spec, service_name, base_url=base_url):
269
+ primary = op.operation_id
270
+ table[primary] = op
271
+ table[f"{op.http_method} {op.path}"] = op
272
+ stripped = op.display_name
273
+ if stripped != primary and stripped not in table:
274
+ table[stripped] = op
275
+ return table
276
+
277
+
278
+ def resolve_operation(table: dict[str, Operation], name: str) -> Operation:
279
+ """Look up an operation by ``name`` (case-insensitive), with helpful errors."""
280
+
281
+ if name in table:
282
+ return table[name]
283
+ lowered = name.lower()
284
+ for key, op in table.items():
285
+ if key.lower() == lowered:
286
+ return op
287
+ raise NotFoundError(
288
+ f"no operation matches '{name}'",
289
+ kind="method_not_found",
290
+ hint="Run `oed <service>` to list every operationId for this service.",
291
+ )
292
+
293
+
294
+ def parse_json_arg(blob: str | None, *, flag: str) -> dict[str, Any] | None:
295
+ """Parse a JSON flag with a precise error pointing at the flag name."""
296
+
297
+ if blob is None or blob == "":
298
+ return None
299
+ try:
300
+ obj = json.loads(blob)
301
+ except json.JSONDecodeError as exc:
302
+ raise UserError(
303
+ f"--{flag} is not valid JSON: {exc.msg} (line {exc.lineno}, col {exc.colno})",
304
+ kind="invalid_json",
305
+ ) from exc
306
+ if not isinstance(obj, dict):
307
+ raise UserError(
308
+ f"--{flag} must decode to a JSON object, got {type(obj).__name__}",
309
+ kind="invalid_json",
310
+ )
311
+ return obj
312
+
313
+
314
+ def coerce_param_types(op: Operation, params: dict[str, Any]) -> dict[str, Any]:
315
+ """Cast well-known string params to int/float based on the OpenAPI schema.
316
+
317
+ openEuler specs declare ``schema.type: integer`` for fields like
318
+ ``page_num``/``count_per_page``; many callers pass them as strings.
319
+ Without coercion the backend rejects them. We only convert ints the
320
+ spec actually declares, so we never mis-cast user data.
321
+ """
322
+
323
+ out: dict[str, Any] = {}
324
+ declared_ints = {
325
+ p["name"]
326
+ for p in op.parameters
327
+ if p.get("schema", {}).get("type") == "integer" and p.get("name") in params
328
+ }
329
+ declared_numbers = {
330
+ p["name"]
331
+ for p in op.parameters
332
+ if p.get("schema", {}).get("type") == "number" and p.get("name") in params
333
+ }
334
+ for k, v in params.items():
335
+ if k in declared_ints and isinstance(v, str) and v.lstrip("-").isdigit():
336
+ out[k] = int(v)
337
+ elif k in declared_numbers and isinstance(v, str):
338
+ try:
339
+ out[k] = float(v)
340
+ except ValueError:
341
+ out[k] = v
342
+ else:
343
+ out[k] = v
344
+ return out
345
+
346
+
347
+ def coerce_flag_value(param_def: dict[str, Any], value: str) -> Any:
348
+ """Coerce one CLI string flag to the spec-declared type.
349
+
350
+ CLI flags arrive as strings (``--page-num 3`` → ``"3"``), but the
351
+ OpenAPI schema often declares ``integer``. Callers can also pass
352
+ already-typed values via ``--params '{...}'``; those bypass this
353
+ helper and go through :func:`coerce_param_types` instead.
354
+ """
355
+
356
+ schema_type = (param_def.get("schema") or {}).get("type", "string")
357
+ if schema_type == "integer":
358
+ stripped = value.lstrip("-")
359
+ if stripped.isdigit():
360
+ return int(value)
361
+ return value
362
+ if schema_type == "number":
363
+ try:
364
+ return float(value)
365
+ except ValueError:
366
+ return value
367
+ if schema_type == "boolean":
368
+ lowered = value.lower()
369
+ if lowered in {"true", "1", "yes", "on"}:
370
+ return True
371
+ if lowered in {"false", "0", "no", "off"}:
372
+ return False
373
+ return value
374
+
375
+
376
+ # --------------------------------------------------------------------------- #
377
+ # Local spec cache (per-service file, mirrors discovery.py layout)
378
+ # --------------------------------------------------------------------------- #
379
+
380
+
381
+ def _spec_cache_path(community: str, service_name: str) -> Path:
382
+ from .discovery import _cache_dir # reuse the XDG resolver
383
+
384
+ return _cache_dir() / "specs" / community / f"{service_name}.json"
385
+
386
+
387
+ def _read_spec_cache(path: Path) -> dict[str, Any] | None:
388
+ if not path.is_file():
389
+ return None
390
+ try:
391
+ raw = json.loads(path.read_text(encoding="utf-8"))
392
+ except (OSError, json.JSONDecodeError):
393
+ return None
394
+ age = time.time() - float(raw.get("__oed_fetched_at", 0))
395
+ if age >= SPEC_CACHE_TTL_SECONDS:
396
+ return None
397
+ spec = raw.get("spec")
398
+ return spec if isinstance(spec, dict) else None
399
+
400
+
401
+ def _write_spec_cache(path: Path, spec: dict[str, Any]) -> None:
402
+ payload = {
403
+ "__oed_fetched_at": time.time(),
404
+ "spec": spec,
405
+ }
406
+ try:
407
+ path.parent.mkdir(parents=True, exist_ok=True)
408
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
409
+ except OSError:
410
+ return # best-effort
411
+
412
+
413
+ def fetch_service_spec(
414
+ service: ServiceMeta, *, force_refresh: bool = False
415
+ ) -> dict[str, Any]:
416
+ """Load one service's OpenAPI, using a file cache when fresh."""
417
+
418
+ if not force_refresh:
419
+ cached = _read_spec_cache(_spec_cache_path(service.community, service.service_name))
420
+ if cached is not None:
421
+ return cached
422
+
423
+ url = SPEC_URL_TEMPLATE.format(community=service.community, service_name=service.service_name)
424
+ spec = request_json("GET", url)
425
+ if not isinstance(spec, dict) or "openapi" not in spec:
426
+ raise NotFoundError(
427
+ f"GET {url} did not return an OpenAPI document",
428
+ kind="spec_missing",
429
+ hint="The discovery feed lists this service but its OpenAPI spec is not available.",
430
+ )
431
+ _write_spec_cache(_spec_cache_path(service.community, service.service_name), spec)
432
+ return spec
433
+
434
+
435
+ def resolve_service(community: str | None = None, force_refresh: bool = False) -> ServiceMeta:
436
+ """Find the service named by the ``OED_SERVICE`` env var (used in tests).
437
+
438
+ Real CLI dispatch uses :func:`resolve_service_by_name`. This helper exists
439
+ so that scripts / tests can still drive ``invoke.call_operation`` without
440
+ going through the full dispatch table.
441
+ """
442
+
443
+ name = os.environ.get("OED_SERVICE")
444
+ if not name:
445
+ raise UserError(
446
+ "OED_SERVICE env var is not set; pass service_name explicitly.",
447
+ kind="missing_service",
448
+ )
449
+ return resolve_service_by_name(name, community=community, force_refresh=force_refresh)
450
+
451
+
452
+ def resolve_service_by_name(
453
+ service_name: str,
454
+ *,
455
+ community: str | None = None,
456
+ force_refresh: bool = False,
457
+ ) -> ServiceMeta:
458
+ """Locate a :class:`ServiceMeta` by ``service_name`` in the given/active community."""
459
+
460
+ feed = fetch_discovery(force_refresh=force_refresh)
461
+ target_community = community or current_community()
462
+ matches = [s for s in feed.services if s.service_name == service_name]
463
+ if not matches:
464
+ other = sorted({s.community for s in feed.services})
465
+ raise NotFoundError(
466
+ f"service '{service_name}' is not registered",
467
+ kind="service_not_found",
468
+ hint=(
469
+ f"Available communities: {other}. "
470
+ "Run `oed services` to list registered services."
471
+ ),
472
+ )
473
+ if len(matches) == 1:
474
+ return matches[0]
475
+ for s in matches:
476
+ if s.community == target_community:
477
+ return s
478
+ other_communities = sorted({s.community for s in matches})
479
+ raise UserError(
480
+ f"service '{service_name}' is registered in multiple communities {other_communities}; "
481
+ f"set OED_COMMUNITY to pick one (current: {target_community}).",
482
+ kind="ambiguous_service",
483
+ )
484
+
485
+
486
+ def operations_for(
487
+ service_name: str,
488
+ *,
489
+ community: str | None = None,
490
+ force_refresh: bool = False,
491
+ ) -> tuple[ServiceMeta, dict[str, Operation], dict[str, Any]]:
492
+ """One-shot helper: resolve a service, fetch its spec, build the table.
493
+
494
+ Returns ``(service_meta, operations_table, raw_spec)`` so callers can
495
+ inspect both the structured view and the raw OpenAPI doc. Each
496
+ operation in the returned table has ``base_url`` set to
497
+ :func:`resolve_runtime_gateway` of the resolved service.
498
+ """
499
+
500
+ service = resolve_service_by_name(
501
+ service_name, community=community, force_refresh=force_refresh
502
+ )
503
+ spec = fetch_service_spec(service, force_refresh=force_refresh)
504
+ base_url = resolve_runtime_gateway(service)
505
+ return service, operations_table(spec, service.service_name, base_url=base_url), spec
506
+
507
+
508
+ __all__ = [
509
+ "Backend",
510
+ "Operation",
511
+ "collect_operations",
512
+ "coerce_flag_value",
513
+ "coerce_param_types",
514
+ "fetch_service_spec",
515
+ "operations_for",
516
+ "operations_table",
517
+ "param_flag_index",
518
+ "parse_json_arg",
519
+ "resolve_operation",
520
+ "resolve_runtime_gateway",
521
+ "resolve_service_by_name",
522
+ "to_flag",
523
+ ]
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