"""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, NoReturn from urllib.parse import unquote, urldefrag, urljoin, urlsplit import httpx _INHERITABLE_PARAMETER_LOCATIONS = frozenset({"path", "query"}) 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): param = {**param, "schema": deref_schema(spec, schema)} return param def operation_parameters( spec: dict[str, Any], path_item: dict[str, Any], op: dict[str, Any] ) -> list[dict[str, Any]]: """Return an operation's parameters, including those inherited from its path item. OpenAPI 3.x lets a Path Item Object declare ``parameters`` that apply to every operation under that path. An operation-level parameter overrides an inherited one when both match on ``(name, in)``; anything else is inherited as-is. Only ``in: path`` and ``in: query`` are inherited. A path-item ``in: header`` or ``in: cookie`` entry is deliberately dropped: those do not translate into a Python interface function, and outbound headers belong to transport configuration (``SystemSettings.PythonSettings.http.headers``, the CLI's ``-H``) or to security-scheme handling — not to a per-command argument. Entries are returned dereferenced, inherited first, then the operation's own. """ inherited = path_item.get("parameters") or [] own_raw = op.get("parameters") or [] own = [deref_parameter(spec, p) for p in own_raw if isinstance(p, dict)] own = [p for p in own if p] if not inherited: return own overridden = {(p.get("name"), p.get("in")) for p in own} merged: list[dict[str, Any]] = [] for raw in inherited: if not isinstance(raw, dict): continue resolved = deref_parameter(spec, raw) if not resolved or not resolved.get("name"): continue if resolved.get("in") not in _INHERITABLE_PARAMETER_LOCATIONS: continue if (resolved.get("name"), resolved.get("in")) in overridden: continue merged.append(resolved) merged.extend(own) return merged def path_item_credential_parameters( spec: dict[str, Any], path_item: dict[str, Any] ) -> list[dict[str, Any]]: """Return the path item's required ``header``/``cookie`` parameters, dereferenced. These are exactly what :func:`operation_parameters` drops: a value every operation under the path needs (a tenant id, a session cookie) that isn't a per-call argument. They're a per-integration secret, not different per request, so they route through ``openbb_cli.codegen.credentials`` the same way an ``apiKey`` security scheme does, rather than becoming a required argument the caller has to supply on every command. A path-item parameter that is optional is left for the caller to notice — only a required one is dead-on-arrival without this. """ out: list[dict[str, Any]] = [] for raw in path_item.get("parameters") or []: if not isinstance(raw, dict): continue resolved = deref_parameter(spec, raw) if not resolved or not resolved.get("name"): continue if resolved.get("in") not in ("header", "cookie"): continue if not resolved.get("required"): continue out.append(resolved) return out _NON_SCHEMA_KEYWORDS = frozenset({"default", "example", "examples", "enum", "const"}) 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) # Instance data (``default``, ``example(s)``, ``enum``, ``const``) and # vendor extensions are not subschemas: a ``$ref`` key inside one is a # literal value, not a reference to resolve. return { k: ( v if k in _NON_SCHEMA_KEYWORDS or k.startswith("x-") else 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 _ALLOF_BLOCKING_KEYS = frozenset( {"oneOf", "anyOf", "allOf", "not", "$ref", "enum", "const", "items"} ) def _mergeable_allof_members(members: list[Any]) -> list[dict[str, Any]] | None: """Return the members if every one is a plain object subschema, else ``None``. A member is not mergeable when it carries its own combinator (``oneOf`` / ``anyOf`` / a nested ``allOf`` that did not collapse), an unresolved ``$ref`` left behind by a reference cycle, a scalar constraint (``enum`` / ``const`` / ``items``), or a ``type`` other than ``object``. Any of those means the composition is not a plain intersection of property bags, and merging it would describe something the spec does not. Everything else is annotation that does not affect the intersection — ``title``, ``description``, ``examples``, ``definitions``, vendor ``x-`` keys — and does not block the merge. Keying off a blocklist rather than a whitelist matters: Codat's ``PagingInfo`` carries ``definitions``, and treating that as disqualifying left the composition unmerged. """ out: list[dict[str, Any]] = [] for member in members: if not isinstance(member, dict): return None if member.keys() & _ALLOF_BLOCKING_KEYS: return None declared = member.get("type") if declared is not None and declared != "object": return None out.append(member) return out def merge_allof(node: Any, max_depth: int = 32) -> Any: """Collapse ``allOf`` compositions of object subschemas into one object schema. An ``allOf`` is an intersection: the instance must satisfy every member. A consumer reading ``properties`` off the composition itself finds nothing, because the properties live one level down inside the members. This merges them into a single object schema so the composition can be read like any other object. ``properties`` are unioned, with the first member to declare a name winning on conflict, and any sibling ``properties`` on the ``allOf`` node itself taking precedence over all members. ``required`` is unioned. Compositions that are not a plain intersection of object subschemas are returned unchanged. """ if max_depth <= 0: return node if isinstance(node, list): return [merge_allof(v, max_depth - 1) for v in node] if not isinstance(node, dict): return node # ``default``, ``example(s)``, ``enum`` and ``const`` hold instance data, not # subschemas, and a vendor extension can hold anything. Recursing into them # would rewrite a value that merely happens to contain an ``allOf`` key — # a default of ``{"allOf": [...]}`` came back as ``{"type": "object"}``. node = { k: ( v if k in _NON_SCHEMA_KEYWORDS or k.startswith("x-") else merge_allof(v, max_depth - 1) ) for k, v in node.items() } members = node.get("allOf") if not isinstance(members, list) or not members: return node mergeable = _mergeable_allof_members(members) if mergeable is None: return node properties: dict[str, Any] = {} required: list[str] = [] for member in mergeable: for name, subschema in (member.get("properties") or {}).items(): properties.setdefault(name, subschema) for name in member.get("required") or []: if name not in required: required.append(name) siblings = {k: v for k, v in node.items() if k != "allOf"} # Keywords written next to the ``allOf`` describe the composition itself and # outrank anything the members declare. for name, subschema in (siblings.get("properties") or {}).items(): properties[name] = subschema for name in siblings.get("required") or []: if name not in required: required.append(name) merged: dict[str, Any] = {**siblings, "type": "object"} if properties: merged["properties"] = properties if required: merged["required"] = required return merged _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 _accepted_content_types(accept: str | None) -> list[str]: """Media types from an ``Accept`` header value, in the order given.""" return [ part.split(";")[0].strip().lower() for part in (accept or "").split(",") if part.split(";")[0].strip() ] def extract_response_schema( spec: dict[str, Any], operation: dict[str, Any], accept: str | None = None ) -> dict[str, Any] | None: """Pull the primary success-response JSON schema for an operation. When ``accept`` (an ``Accept`` header sent with every request) names a media type the response declares, that schema is used, since it is the shape the server will actually return. """ responses = operation.get("responses") or {} response: dict[str, Any] | None = None # Success responses only. ``default`` is the catch-all for *errors* whenever # the operation declares any explicit status code, so it only counts when it # is the operation's sole response; a 4xx/5xx schema is never a row model. success = [ k for k in responses if str(k).upper() in _SUCCESS_PRIORITY[:-1] or str(k).startswith("2") ] candidates = sorted( success, key=lambda k: ( _SUCCESS_PRIORITY.index(str(k).upper()) if str(k).upper() in _SUCCESS_PRIORITY else len(_SUCCESS_PRIORITY) ), ) if not candidates and set(responses) == {"default"}: candidates = ["default"] for key in candidates: candidate = responses.get(key) if isinstance(candidate, dict): response = _deref_response(spec, candidate) break if response is None: return None content = response.get("content") or {} media: dict[str, Any] | None = None for ct in [*_accepted_content_types(accept), *_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 merge_allof(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 merge_allof(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] = merge_allof(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)``.""" combinator = "anyOf" if "anyOf" in schema else "oneOf" if "oneOf" in schema else "" if combinator: non_null = [s for s in schema[combinator] 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: schema_type = s.get("type") if schema_type == "array" or ( isinstance(schema_type, list) and "array" in schema_type ): is_list = True for v in s.get("enum", []): if v not in choices: choices.append(v) return (str, choices, is_list) schema_type = schema.get("type", "string") schema_types = schema_type if isinstance(schema_type, list) else [schema_type] if "array" in schema_types: 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) scalar_type = next( (item for item in schema_types if item not in {"array", "null"}), "string" ) py_type = _TYPE_MAP.get(scalar_type, 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.""" schema_type = schema.get("type") schema_types = schema_type if isinstance(schema_type, list) else [schema_type] if "object" in schema_types or "properties" in schema: return True if schema.get("additionalProperties"): return True if "array" in schema_types: 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. A body counts as an object when it declares ``type: object`` or carries ``properties`` — the latter is how a normalized 3.1 nullable object (``type: ["object", "null"]``) arrives: ``expand_type_arrays`` moves the type into ``anyOf`` variants while ``properties`` stays on the parent. Failing both, an ``anyOf`` with exactly one object variant is unwrapped to that variant. """ if not isinstance(body_schema, dict): return [] body_type = body_schema.get("type") body_types = body_type if isinstance(body_type, list) else [body_type] if "object" not in body_types and "properties" not in body_schema: object_members = [ member for member in body_schema.get("anyOf", []) if isinstance(member, dict) and ( "object" in ( member.get("type") if isinstance(member.get("type"), list) else [member.get("type")] ) or "properties" in member ) ] if len(object_members) != 1: return [] body_schema = {**body_schema, **object_members[0]} 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, path_item: dict[str, Any] | None = None, ) -> argparse.ArgumentParser: """Build an ``ArgumentParser`` from an OpenAPI operation object. When ``path_item`` is given, parameters shared by every operation under that path are inherited by the parser (see :func:`operation_parameters`). """ 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 [] ) params = ( operation_parameters(spec, path_item or {}, op) if spec is not None else list(op.get("parameters", []) or []) ) for param in [*params, *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 :] # A segment made only of placeholders and punctuation (``{x},{y}``) is not a name. if not any(ch.isalnum() for ch in out): return "" 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}``. A literal ``.`` inside a URL segment (``/v1.1/fundamentals``) becomes ``_`` — dots in command names are exclusively namespace separators. """ 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).replace(".", "_") 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, methods ) 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.replace(".", "_") 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 _resolve_json_pointer(document: Any, fragment: str) -> Any: """Resolve a URI-fragment JSON pointer within one OpenAPI document.""" if not fragment: return document pointer = unquote(fragment) if not pointer.startswith("/"): raise ValueError(f"Unsupported OpenAPI reference fragment: #{fragment}") node = document for raw_part in pointer[1:].split("/"): part = raw_part.replace("~1", "/").replace("~0", "~") if isinstance(node, dict): node = node[part] elif isinstance(node, list): node = node[int(part)] else: raise ValueError(f"Invalid OpenAPI reference fragment: #{fragment}") return node def _url_origin(url: str) -> tuple[str, str, int | None]: """Return a normalized HTTP(S) origin for reference security checks.""" parsed = urlsplit(url) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise ValueError(f"Unsupported OpenAPI reference URL: {url}") default_port = 443 if parsed.scheme == "https" else 80 return parsed.scheme, parsed.hostname.lower(), parsed.port or default_port def _bundle_external_refs( document: dict[str, Any], source_url: str, *, timeout: float, headers: dict[str, str], ) -> dict[str, Any]: """Inline same-origin external refs while preserving root-local refs.""" max_documents = 512 max_document_bytes = 8 * 1024 * 1024 read_chunk_bytes = 64 * 1024 max_depth = 128 root_url, _ = urldefrag(source_url) root_origin = _url_origin(root_url) documents: dict[str, dict[str, Any]] = {root_url: document} def load_document(url: str) -> dict[str, Any]: cached = documents.get(url) if cached is not None: return cached if _url_origin(url) != root_origin: raise ValueError(f"External OpenAPI reference must be same-origin: {url}") if len(documents) >= max_documents: raise ValueError("OpenAPI reference graph exceeds the document limit") with httpx.stream( "GET", url, timeout=timeout, follow_redirects=False, headers=headers, ) as response: if response.is_redirect: raise ValueError( "Redirects are not allowed for external OpenAPI references" ) response.raise_for_status() content_length = response.headers.get("content-length") if content_length and int(content_length) > max_document_bytes: raise ValueError("External OpenAPI reference exceeds the size limit") content = bytearray() for chunk in response.iter_bytes(chunk_size=read_chunk_bytes): if len(chunk) > max_document_bytes - len(content): raise ValueError( "External OpenAPI reference exceeds the size limit" ) content.extend(chunk) text = bytes(content).decode(response.encoding or "utf-8") content_type = response.headers.get("content-type", "") parsed = _ensure_openapi_dict( _parse_spec_text( text, content_type=content_type, ), url, ) documents[url] = parsed return parsed def visit( node: Any, current_url: str, current_document: dict[str, Any], *, imported: bool, seen: frozenset[tuple[str, str]], depth: int, ) -> Any: if depth > max_depth: raise ValueError("OpenAPI reference graph exceeds the depth limit") if isinstance(node, dict): if "$id" in node: raise ValueError("OpenAPI $id resource identifiers are not supported") ref = node.get("$ref") if isinstance(ref, str) and (imported or not ref.startswith("#")): target_url, fragment = urldefrag(urljoin(current_url, ref)) if fragment and not fragment.startswith("/"): raise ValueError("OpenAPI reference anchors are not supported") marker = (target_url, fragment) if marker in seen: return {"$ref": f"{target_url}#{fragment}"} target_document = ( current_document if target_url == current_url else load_document(target_url) ) target = _resolve_json_pointer(target_document, fragment) resolved = visit( target, target_url, target_document, imported=True, seen=seen | {marker}, depth=depth + 1, ) unsupported_siblings = set(node) - {"$ref", "summary", "description"} if unsupported_siblings: raise ValueError( "OpenAPI reference siblings other than summary and description " "are not supported" ) siblings = { key: visit( value, current_url, current_document, imported=imported, seen=seen, depth=depth + 1, ) for key, value in node.items() if key != "$ref" } if isinstance(resolved, dict): return {**resolved, **siblings} return resolved return { key: visit( value, current_url, current_document, imported=imported, seen=seen, depth=depth + 1, ) for key, value in node.items() } if isinstance(node, list): return [ visit( value, current_url, current_document, imported=imported, seen=seen, depth=depth + 1, ) for value in node ] return node return visit( document, root_url, document, imported=False, seen=frozenset(), depth=0, ) def _raise_fetch_failure( response: Any, parse_error: ValueError | None, url: str ) -> NoReturn: """Raise why the spec at ``url`` could not be loaded. Parameters ---------- response : Any The response for the spec URL. parse_error : ValueError | None The error from parsing a successful response, if any. url : str The spec URL. Raises ------ httpx.HTTPStatusError If the response has an error status. ValueError If the response body is not a parseable OpenAPI document. """ response.raise_for_status() raise parse_error or ValueError(f"Could not load an OpenAPI document from {url}") 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, ) parse_error: ValueError | None = None if response.status_code < 400: try: parsed = _ensure_openapi_dict( _parse_spec_text( response.text, content_type=response.headers.get("content-type", ""), ), full_url, ) return _bundle_external_refs( parsed, str(getattr(response, "url", full_url)), timeout=timeout, headers=merged_headers, ) except ValueError as exc: parse_error = exc if explicit_path: _raise_fetch_failure(response, parse_error, 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 _bundle_external_refs( _ensure_openapi_dict(embedded, landing_url), str(getattr(landing, "url", landing_url)), timeout=timeout, headers=merged_headers, ) _raise_fetch_failure(response, parse_error, 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 expand_type_arrays(parsed) def expand_type_arrays(node: Any) -> Any: """Rewrite JSON-Schema ``type`` arrays (OpenAPI 3.1) into ``anyOf`` unions. ``{"type": ["number", "null"]}`` becomes ``{"anyOf": [{"type": "number"}, {"type": "null"}]}`` — the OpenAPI 3.0 shape every schema consumer already understands. Sibling keywords are copied into each non-null variant so type-scoped keys (``format``, ``items``, ``enum``) survive the split. Schemas that already declare ``anyOf`` / ``oneOf`` are left alone: consumers resolve the combinator before ever reading ``type``. """ if isinstance(node, list): return [expand_type_arrays(v) for v in node] if not isinstance(node, dict): return node out = {k: expand_type_arrays(v) for k, v in node.items()} types = out.get("type") if not isinstance(types, list) or "anyOf" in out or "oneOf" in out: return out rest = {k: v for k, v in out.items() if k != "type"} variants = [{"type": "null"} if t == "null" else {**rest, "type": t} for t in types] return {**rest, "anyOf": variants}