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/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)