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/__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 +523 -0
- oed_cli/errors.py +45 -0
- oed_cli/http.py +187 -0
- oed_cli/invoke.py +319 -0
- oed_cli/main.py +414 -0
- oed_cli/py.typed +0 -0
- oed_cli-0.1.0.dist-info/METADATA +373 -0
- oed_cli-0.1.0.dist-info/RECORD +16 -0
- oed_cli-0.1.0.dist-info/WHEEL +5 -0
- oed_cli-0.1.0.dist-info/entry_points.txt +2 -0
- oed_cli-0.1.0.dist-info/licenses/LICENSE +17 -0
- oed_cli-0.1.0.dist-info/top_level.txt +1 -0
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
|