mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-28 13:01:38 +00:00
* stash some changes * add more robust testing * mypy * point PR at V5 * introduce spec file * codespell * test fix * fix workflow environment setup * fix workflow environment setup * fix workflow environment setup * add pyyaml to dependencies * split lint jobs * fix workflow environment setup * fix workflow environment setup * workflow env setup * workflow env setup * clean up code comments * add auth hook entrypoints * codespell * add codegen feature * codespell * move _unpack into dispatchers for consistency with codegen packages * surface nested models in the response * fix missing coverage in CI * socrata updates * test fix * detect plotly output * add --include and --exclude flags from generate-extension command * cap test matrix at python 3.14 * no useless comments * platform controller command description split * merge URL overloads from path params * exclude none and unset from model dump --------- Co-authored-by: deeleeramone <> Co-authored-by: Copilot <copilot@github.com>
699 lines
22 KiB
Python
699 lines
22 KiB
Python
"""Build CLI menu / parser structures from an OpenAPI 3.x schema."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
PROVIDER_TAG_RE = re.compile(r"\s*\(provider:\s*([^)]+)\)\s*$")
|
|
PROVIDER_SECTION_SPLIT_RE = re.compile(r";\s*\n\s*")
|
|
|
|
|
|
def parse_provider_sections(text: str) -> tuple[set[str], bool]:
|
|
"""Split an OpenBB-merged help string into ``(tagged_providers, has_untagged)``."""
|
|
tagged: set[str] = set()
|
|
has_untagged = False
|
|
for raw_section in PROVIDER_SECTION_SPLIT_RE.split(text):
|
|
section = raw_section.strip()
|
|
if not section:
|
|
continue
|
|
match = PROVIDER_TAG_RE.search(section)
|
|
if match is None:
|
|
has_untagged = True
|
|
continue
|
|
for p in match.group(1).split(","):
|
|
name = p.strip().lower()
|
|
if name:
|
|
tagged.add(name)
|
|
return tagged, has_untagged
|
|
|
|
|
|
def param_provider_membership(
|
|
schema: dict[str, Any],
|
|
description: str | None,
|
|
providers_set: set[str],
|
|
) -> list[str]:
|
|
"""Return the providers a parameter belongs to, or ``[]`` if shared by all."""
|
|
providers_lower = {p.lower(): p for p in providers_set}
|
|
if description:
|
|
tagged, has_untagged = parse_provider_sections(description)
|
|
if has_untagged:
|
|
return []
|
|
if tagged:
|
|
return [providers_lower[t] for t in tagged if t in providers_lower]
|
|
keys = [
|
|
k
|
|
for k, v in schema.items()
|
|
if k in providers_set
|
|
and k not in _OPENAPI_RESERVED_SCHEMA_KEYS
|
|
and isinstance(v, dict)
|
|
]
|
|
if keys:
|
|
return keys
|
|
title = schema.get("title")
|
|
if isinstance(title, str) and title.lower() in providers_lower:
|
|
return [providers_lower[title.lower()]]
|
|
return []
|
|
|
|
|
|
_TYPE_MAP: dict[str, type] = {
|
|
"string": str,
|
|
"integer": int,
|
|
"number": float,
|
|
"boolean": bool,
|
|
}
|
|
|
|
_OPENAPI_RESERVED_SCHEMA_KEYS = frozenset(
|
|
{
|
|
"type",
|
|
"format",
|
|
"title",
|
|
"description",
|
|
"default",
|
|
"enum",
|
|
"const",
|
|
"anyOf",
|
|
"oneOf",
|
|
"allOf",
|
|
"items",
|
|
"properties",
|
|
"additionalProperties",
|
|
"required",
|
|
"nullable",
|
|
"minimum",
|
|
"maximum",
|
|
"minLength",
|
|
"maxLength",
|
|
"pattern",
|
|
}
|
|
)
|
|
|
|
|
|
def resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
|
|
"""Resolve a local OpenAPI ``$ref`` JSON pointer against ``spec``."""
|
|
if not ref.startswith("#/"):
|
|
return {}
|
|
node: Any = spec
|
|
for raw_part in ref[2:].split("/"):
|
|
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
if not isinstance(node, dict) or part not in node:
|
|
return {}
|
|
node = node[part]
|
|
return node if isinstance(node, dict) else {}
|
|
|
|
|
|
def deref_parameter(spec: dict[str, Any], param: dict[str, Any]) -> dict[str, Any]:
|
|
"""Resolve a parameter that may be a ``$ref``."""
|
|
seen: set[str] = set()
|
|
while "$ref" in param:
|
|
ref = param["$ref"]
|
|
if ref in seen:
|
|
return {}
|
|
seen.add(ref)
|
|
param = resolve_ref(spec, ref) or {}
|
|
schema = param.get("schema")
|
|
if isinstance(schema, dict) and "$ref" in schema:
|
|
param = {**param, "schema": resolve_ref(spec, schema["$ref"]) or schema}
|
|
return param
|
|
|
|
|
|
def deref_schema(
|
|
spec: dict[str, Any],
|
|
node: Any,
|
|
seen: frozenset[str] | None = None,
|
|
max_depth: int = 32,
|
|
) -> Any:
|
|
"""Recursively expand every ``$ref`` inside an OpenAPI schema fragment."""
|
|
if max_depth <= 0:
|
|
return node
|
|
if seen is None:
|
|
seen = frozenset()
|
|
if isinstance(node, dict):
|
|
ref = node.get("$ref")
|
|
if isinstance(ref, str):
|
|
if ref in seen:
|
|
return {"$ref": ref}
|
|
target = resolve_ref(spec, ref)
|
|
if not target:
|
|
return node
|
|
return deref_schema(spec, target, seen | {ref}, max_depth - 1)
|
|
return {k: deref_schema(spec, v, seen, max_depth - 1) for k, v in node.items()}
|
|
if isinstance(node, list):
|
|
return [deref_schema(spec, v, seen, max_depth - 1) for v in node]
|
|
return node
|
|
|
|
|
|
_SUCCESS_PRIORITY = ("200", "2XX", "201", "default")
|
|
_JSON_CONTENT_TYPES = ("application/json", "application/vnd.api+json")
|
|
|
|
|
|
def _deref_response(spec: dict[str, Any], response: dict[str, Any]) -> dict[str, Any]:
|
|
if "$ref" in response:
|
|
return resolve_ref(spec, response["$ref"]) or {}
|
|
return response
|
|
|
|
|
|
def extract_response_schema(
|
|
spec: dict[str, Any], operation: dict[str, Any]
|
|
) -> dict[str, Any] | None:
|
|
"""Pull the primary success-response JSON schema for an operation."""
|
|
responses = operation.get("responses") or {}
|
|
response: dict[str, Any] | None = None
|
|
for key in _SUCCESS_PRIORITY:
|
|
candidate = responses.get(key)
|
|
if isinstance(candidate, dict):
|
|
response = _deref_response(spec, candidate)
|
|
break
|
|
if response is None:
|
|
for value in responses.values():
|
|
if isinstance(value, dict):
|
|
response = _deref_response(spec, value)
|
|
break
|
|
if response is None:
|
|
return None
|
|
content = response.get("content") or {}
|
|
media: dict[str, Any] | None = None
|
|
for ct in _JSON_CONTENT_TYPES:
|
|
candidate = content.get(ct)
|
|
if isinstance(candidate, dict):
|
|
media = candidate
|
|
break
|
|
if media is None:
|
|
for value in content.values():
|
|
if isinstance(value, dict) and "schema" in value:
|
|
media = value
|
|
break
|
|
if media is None:
|
|
return None
|
|
schema = media.get("schema")
|
|
if not isinstance(schema, dict):
|
|
return None
|
|
return deref_schema(spec, schema)
|
|
|
|
|
|
def extract_request_body_schema(
|
|
spec: dict[str, Any], operation: dict[str, Any]
|
|
) -> dict[str, Any] | None:
|
|
"""Pull the primary request-body JSON schema for an operation."""
|
|
rb = operation.get("requestBody")
|
|
if not isinstance(rb, dict):
|
|
return None
|
|
if "$ref" in rb:
|
|
rb = resolve_ref(spec, rb["$ref"]) or {}
|
|
content = rb.get("content") or {}
|
|
media: dict[str, Any] | None = None
|
|
for ct in _JSON_CONTENT_TYPES:
|
|
candidate = content.get(ct)
|
|
if isinstance(candidate, dict):
|
|
media = candidate
|
|
break
|
|
if media is None:
|
|
for value in content.values():
|
|
if isinstance(value, dict) and "schema" in value:
|
|
media = value
|
|
break
|
|
if media is None:
|
|
return None
|
|
schema = media.get("schema")
|
|
if not isinstance(schema, dict):
|
|
return None
|
|
return deref_schema(spec, schema)
|
|
|
|
|
|
def extract_response_schemas(
|
|
spec: dict[str, Any], operation: dict[str, Any]
|
|
) -> dict[str, dict[str, dict[str, Any]]]:
|
|
"""Return every response schema as ``{status_code: {content_type: schema}}``."""
|
|
out: dict[str, dict[str, dict[str, Any]]] = {}
|
|
responses = operation.get("responses") or {}
|
|
for status, response in responses.items():
|
|
if not isinstance(response, dict):
|
|
continue
|
|
deref_resp = _deref_response(spec, response)
|
|
content = deref_resp.get("content") or {}
|
|
per_content: dict[str, dict[str, Any]] = {}
|
|
for content_type, media in content.items():
|
|
if not isinstance(media, dict):
|
|
continue
|
|
schema = media.get("schema")
|
|
if isinstance(schema, dict):
|
|
per_content[content_type] = deref_schema(spec, schema)
|
|
if per_content:
|
|
out[status] = per_content
|
|
return out
|
|
|
|
|
|
def _resolve_schema(schema: dict[str, Any]) -> tuple[type, list[Any], bool]:
|
|
"""Resolve an OpenAPI parameter schema to ``(py_type, choices, is_list)``."""
|
|
if "anyOf" in schema:
|
|
non_null = [s for s in schema["anyOf"] if s.get("type") != "null"]
|
|
if not non_null:
|
|
return (str, [], False)
|
|
if len(non_null) == 1:
|
|
return _resolve_schema(non_null[0])
|
|
choices: list[Any] = []
|
|
is_list = False
|
|
for s in non_null:
|
|
if s.get("type") == "array":
|
|
is_list = True
|
|
for v in s.get("enum", []):
|
|
if v not in choices:
|
|
choices.append(v)
|
|
return (str, choices, is_list)
|
|
|
|
if schema.get("type") == "array":
|
|
items = schema.get("items", {}) or {}
|
|
py_type, item_choices, _ = _resolve_schema(items)
|
|
return (py_type, item_choices, True)
|
|
|
|
if "const" in schema:
|
|
return (type(schema["const"]), [schema["const"]], False)
|
|
|
|
py_type = _TYPE_MAP.get(schema.get("type", "string"), str)
|
|
return (py_type, list(schema.get("enum", [])), False)
|
|
|
|
|
|
def _is_json_arg(schema: dict[str, Any]) -> bool:
|
|
"""Whether a request-body field must be supplied as a raw JSON value."""
|
|
if schema.get("type") == "object" or "properties" in schema:
|
|
return True
|
|
if schema.get("additionalProperties"):
|
|
return True
|
|
if schema.get("type") == "array":
|
|
items = schema.get("items")
|
|
return isinstance(items, dict) and _is_json_arg(items)
|
|
return any(
|
|
isinstance(member, dict) and _is_json_arg(member)
|
|
for member in schema.get("anyOf", []) or []
|
|
)
|
|
|
|
|
|
def parse_json_arg(raw: str) -> Any:
|
|
"""Decode a CLI argument value that carries a JSON document."""
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
|
|
|
|
|
|
def request_body_parameters(
|
|
body_schema: dict[str, Any] | None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Flatten a dereferenced request-body schema into OpenAPI parameter objects."""
|
|
if not isinstance(body_schema, dict) or body_schema.get("type") != "object":
|
|
return []
|
|
required = set(body_schema.get("required") or [])
|
|
out: list[dict[str, Any]] = []
|
|
for name, prop in (body_schema.get("properties") or {}).items():
|
|
if not isinstance(prop, dict):
|
|
continue
|
|
out.append(
|
|
{
|
|
"name": name,
|
|
"in": "body",
|
|
"required": name in required,
|
|
"description": prop.get("description") or prop.get("title"),
|
|
"schema": prop,
|
|
"_json_arg": _is_json_arg(prop),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _provider_choices(schema: dict[str, Any]) -> list[Any]:
|
|
"""Union of per-provider ``choices`` lists found in OpenBB extension keys."""
|
|
out: list[Any] = []
|
|
for key, value in schema.items():
|
|
if key in _OPENAPI_RESERVED_SCHEMA_KEYS:
|
|
continue
|
|
if isinstance(value, dict) and isinstance(value.get("choices"), list):
|
|
for c in value["choices"]:
|
|
if c not in out:
|
|
out.append(c)
|
|
return out
|
|
|
|
|
|
def _escape_help(text: str | None) -> str | None:
|
|
"""Escape lone ``%`` so argparse's %-formatting validator accepts the help text."""
|
|
if text is None:
|
|
return None
|
|
return text.replace("%", "%%")
|
|
|
|
|
|
def parameter_to_kwargs(param: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
|
|
"""Translate one OpenAPI parameter to ``(flag, add_argument kwargs)``."""
|
|
name = param.get("name")
|
|
if not name or name == "chart":
|
|
return None
|
|
|
|
schema = param.get("schema", {}) or {}
|
|
py_type, enum_choices, is_list = _resolve_schema(schema)
|
|
provider_choices = _provider_choices(schema)
|
|
choices: list[Any] = list(enum_choices)
|
|
for c in provider_choices:
|
|
if c not in choices:
|
|
choices.append(c)
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"dest": name,
|
|
"help": _escape_help(param.get("description") or schema.get("description")),
|
|
}
|
|
|
|
if param.get("_json_arg"):
|
|
kwargs["type"] = parse_json_arg
|
|
if param.get("required"):
|
|
kwargs["required"] = True
|
|
return (f"--{name}", kwargs)
|
|
|
|
if py_type is bool:
|
|
kwargs.update(action="store_true", default=bool(schema.get("default", False)))
|
|
return (f"--{name}", kwargs)
|
|
|
|
kwargs["type"] = py_type
|
|
if "default" in schema:
|
|
kwargs["default"] = schema["default"]
|
|
if choices:
|
|
kwargs["choices"] = choices
|
|
if is_list:
|
|
kwargs["nargs"] = "+"
|
|
if param.get("required") and "default" not in kwargs:
|
|
kwargs["required"] = True
|
|
|
|
return (f"--{name}", kwargs)
|
|
|
|
|
|
def build_parser_from_operation(
|
|
op: dict[str, Any], spec: dict[str, Any] | None = None
|
|
) -> argparse.ArgumentParser:
|
|
"""Build an ``ArgumentParser`` from an OpenAPI operation object."""
|
|
parser = argparse.ArgumentParser(
|
|
prog=op.get("operationId", "cmd"),
|
|
description=(op.get("description") or op.get("summary") or "").strip() or None,
|
|
add_help=False,
|
|
formatter_class=argparse.RawTextHelpFormatter,
|
|
)
|
|
body_params = (
|
|
request_body_parameters(extract_request_body_schema(spec, op))
|
|
if spec is not None
|
|
else []
|
|
)
|
|
for param in [*op.get("parameters", []), *body_params]:
|
|
translated = parameter_to_kwargs(param)
|
|
if translated is None:
|
|
continue
|
|
flag, kwargs = translated
|
|
try:
|
|
parser.add_argument(flag, **kwargs)
|
|
except argparse.ArgumentError:
|
|
continue
|
|
return parser
|
|
|
|
|
|
def _strip_placeholders(segment: str) -> str:
|
|
"""Remove ``{placeholder}`` substrings from a URL segment."""
|
|
out = segment
|
|
while "{" in out and "}" in out:
|
|
i = out.find("{")
|
|
j = out.find("}", i)
|
|
if j < 0:
|
|
break
|
|
out = out[:i] + out[j + 1 :]
|
|
return out.strip(".")
|
|
|
|
|
|
def detect_api_prefix(spec: dict[str, Any]) -> str:
|
|
"""Compute the longest slash-segmented prefix shared by every path."""
|
|
paths = list(spec.get("paths", {}).keys())
|
|
if not paths:
|
|
return "/api/v1"
|
|
parts_lists = [p.split("/")[:-1] for p in paths]
|
|
common: list[str] = parts_lists[0]
|
|
for parts in parts_lists[1:]:
|
|
new_common: list[str] = []
|
|
for a, b in zip(common, parts):
|
|
if a == b:
|
|
new_common.append(a)
|
|
else:
|
|
break
|
|
common = new_common
|
|
if not common:
|
|
break
|
|
prefix = "/".join(common).rstrip("/")
|
|
return prefix or ""
|
|
|
|
|
|
def url_to_command(url: str, api_prefix: str = "/api/v1") -> str:
|
|
"""Convert a URL path to a dotted command, dropping ``{path_params}``."""
|
|
prefix_parts = [p for p in api_prefix.strip("/").split("/") if p]
|
|
parts = [p for p in url.strip("/").split("/") if p]
|
|
if parts[: len(prefix_parts)] == prefix_parts:
|
|
parts = parts[len(prefix_parts) :]
|
|
cleaned = [_strip_placeholders(p) for p in parts]
|
|
return ".".join(p for p in cleaned if p)
|
|
|
|
|
|
def build_command_index(
|
|
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
|
|
) -> dict[str, argparse.ArgumentParser]:
|
|
"""Map each ``paths`` entry in the spec to an ArgumentParser by dotted command."""
|
|
index: dict[str, argparse.ArgumentParser] = {}
|
|
for url, methods in spec.get("paths", {}).items():
|
|
op = methods.get("get") or methods.get("post")
|
|
if not op:
|
|
continue
|
|
index[url_to_command(url, api_prefix=api_prefix)] = build_parser_from_operation(
|
|
op, spec
|
|
)
|
|
return index
|
|
|
|
|
|
def build_router_map(
|
|
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
|
|
) -> dict[str, str]:
|
|
"""Classify every dotted path in ``spec`` as ``"menu"`` or ``"command"``."""
|
|
out: dict[str, str] = {}
|
|
for url, methods in spec.get("paths", {}).items():
|
|
if not (methods.get("get") or methods.get("post")):
|
|
continue
|
|
cmd = url_to_command(url, api_prefix=api_prefix)
|
|
if not cmd:
|
|
continue
|
|
parts = cmd.split(".")
|
|
for i in range(1, len(parts)):
|
|
out[".".join(parts[:i])] = "menu"
|
|
out.setdefault(cmd, "command")
|
|
return out
|
|
|
|
|
|
def build_reference(
|
|
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
|
|
) -> dict[str, Any]:
|
|
"""Mimic ``obb.reference`` — ``paths`` for commands, ``routers`` for menus."""
|
|
prefix_parts = [p for p in api_prefix.strip("/").split("/") if p]
|
|
tag_descriptions: dict[str, str] = {
|
|
t["name"]: t.get("description", "")
|
|
for t in spec.get("tags", [])
|
|
if isinstance(t, dict) and t.get("name")
|
|
}
|
|
|
|
router_map = build_router_map(spec, api_prefix=api_prefix)
|
|
|
|
paths_out: dict[str, dict[str, Any]] = {}
|
|
routers_out: dict[str, dict[str, Any]] = {}
|
|
for url, methods in spec.get("paths", {}).items():
|
|
op = methods.get("get") or methods.get("post")
|
|
if not op:
|
|
continue
|
|
parts = [p for p in url.strip("/").split("/") if p]
|
|
if parts[: len(prefix_parts)] == prefix_parts:
|
|
parts = parts[len(prefix_parts) :]
|
|
non_template = [p for p in parts if not (p.startswith("{") and p.endswith("}"))]
|
|
cli_path = "/" + "/".join(non_template)
|
|
op_desc = (op.get("description") or op.get("summary") or "").strip()
|
|
if cli_path not in paths_out or not paths_out[cli_path].get("description"):
|
|
paths_out[cli_path] = {"description": op_desc}
|
|
tags = op.get("tags") or []
|
|
tag_desc = tag_descriptions.get(tags[0]) if tags else ""
|
|
for i in range(1, len(non_template) + 1):
|
|
dotted = ".".join(non_template[:i])
|
|
if router_map.get(dotted) != "menu":
|
|
continue
|
|
menu_path = "/" + "/".join(non_template[:i]) + "/"
|
|
if not routers_out.get(menu_path, {}).get("description"):
|
|
routers_out[menu_path] = {"description": tag_desc or ""}
|
|
return {"paths": paths_out, "routers": routers_out}
|
|
|
|
|
|
def _parse_spec_text(text: str, *, content_type: str = "") -> dict[str, Any]:
|
|
"""Parse a fetched spec body, choosing JSON or YAML by content sniff."""
|
|
stripped = text.lstrip()
|
|
if stripped.startswith(("{", "[")):
|
|
return json.loads(text)
|
|
if "yaml" in content_type or "yml" in content_type:
|
|
return _yaml_load(text)
|
|
if stripped[:8] in ("openapi:", "swagger:"):
|
|
return _yaml_load(text)
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return _yaml_load(text)
|
|
|
|
|
|
def _yaml_load(text: str) -> dict[str, Any]:
|
|
"""Parse a YAML document."""
|
|
import yaml
|
|
|
|
return yaml.safe_load(text)
|
|
|
|
|
|
_EMBEDDED_SPEC_MARKERS: tuple[str, ...] = (
|
|
"var spec = ",
|
|
"const spec = ",
|
|
"let spec = ",
|
|
"window.spec = ",
|
|
'"spec":',
|
|
"spec:",
|
|
)
|
|
|
|
|
|
def _find_matching_brace(text: str, start: int) -> int | None:
|
|
"""Return the index *after* the ``}`` that matches the ``{`` at ``start``."""
|
|
if start >= len(text) or text[start] != "{":
|
|
return None
|
|
depth = 0
|
|
in_string = False
|
|
escape = False
|
|
for i in range(start, len(text)):
|
|
c = text[i]
|
|
if escape:
|
|
escape = False
|
|
continue
|
|
if c == "\\":
|
|
escape = True
|
|
continue
|
|
if c == '"':
|
|
in_string = not in_string
|
|
continue
|
|
if in_string:
|
|
continue
|
|
if c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i + 1
|
|
return None
|
|
|
|
|
|
def _extract_embedded_spec(html: str) -> dict[str, Any] | None:
|
|
"""Scan an HTML page for an embedded OpenAPI / Swagger spec."""
|
|
for marker in _EMBEDDED_SPEC_MARKERS:
|
|
idx = 0
|
|
while True:
|
|
i = html.find(marker, idx)
|
|
if i < 0:
|
|
break
|
|
start = i + len(marker)
|
|
while start < len(html) and html[start] in " \t\r\n":
|
|
start += 1
|
|
end = _find_matching_brace(html, start)
|
|
if end is None:
|
|
idx = i + 1
|
|
continue
|
|
try:
|
|
obj = json.loads(html[start:end])
|
|
except json.JSONDecodeError:
|
|
idx = i + 1
|
|
continue
|
|
if isinstance(obj, dict) and ("openapi" in obj or "swagger" in obj):
|
|
return obj
|
|
idx = end
|
|
return None
|
|
|
|
|
|
def fetch_openapi(
|
|
base_url: str,
|
|
*,
|
|
timeout: float = 10.0,
|
|
path: str | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
query_params: dict[str, str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Fetch the OpenAPI spec at ``base_url`` and return the parsed dict."""
|
|
explicit_path = path is not None
|
|
full_url = (
|
|
path
|
|
if path and (path.startswith("http://") or path.startswith("https://"))
|
|
else f"{base_url.rstrip('/')}{path or '/openapi.json'}"
|
|
)
|
|
merged_headers = {"User-Agent": "openbb-cli/1.0"}
|
|
if headers:
|
|
merged_headers.update(headers)
|
|
|
|
response = httpx.get(
|
|
full_url,
|
|
timeout=timeout,
|
|
follow_redirects=True,
|
|
headers=merged_headers,
|
|
params=query_params or None,
|
|
)
|
|
if response.status_code < 400:
|
|
try:
|
|
return _ensure_openapi_dict(
|
|
_parse_spec_text(
|
|
response.text,
|
|
content_type=response.headers.get("content-type", ""),
|
|
),
|
|
full_url,
|
|
)
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
|
|
if explicit_path:
|
|
response.raise_for_status()
|
|
return _ensure_openapi_dict(
|
|
_parse_spec_text(
|
|
response.text,
|
|
content_type=response.headers.get("content-type", ""),
|
|
),
|
|
full_url,
|
|
)
|
|
|
|
landing_url = base_url.rstrip("/") + "/"
|
|
landing = httpx.get(
|
|
landing_url,
|
|
timeout=timeout,
|
|
follow_redirects=True,
|
|
headers=merged_headers,
|
|
params=query_params or None,
|
|
)
|
|
landing.raise_for_status()
|
|
embedded = _extract_embedded_spec(landing.text)
|
|
if embedded is not None:
|
|
return embedded
|
|
|
|
response.raise_for_status()
|
|
return _ensure_openapi_dict(
|
|
_parse_spec_text(
|
|
response.text,
|
|
content_type=response.headers.get("content-type", ""),
|
|
),
|
|
full_url,
|
|
)
|
|
|
|
|
|
def _ensure_openapi_dict(parsed: Any, source_url: str) -> dict[str, Any]:
|
|
"""Reject parsed bodies that aren't a JSON object (and thus can't be an OpenAPI doc)."""
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError(
|
|
f"{source_url!r} returned a "
|
|
f"{type(parsed).__name__}, not an OpenAPI document. "
|
|
"Pass --openapi-path to point at the real spec endpoint "
|
|
"(e.g. /swagger/v1/swagger.json)."
|
|
)
|
|
return parsed
|