[V5] openbb-cli: Modular OpenAPI Refs (#7589)

* follow up to #7585

* add another codegen test
This commit is contained in:
Danglewood
2026-07-14 12:49:37 -07:00
committed by GitHub
parent 92744a33cd
commit 5b20e24352
13 changed files with 750 additions and 46 deletions

View File

@@ -455,6 +455,14 @@ def _generate_spec(
else:
source_url = server_url.rstrip("/") + "/openapi.json"
spec_doc = build_spec_document(openapi, base_url=server_url, source_url=source_url)
if not spec_doc["commands"]:
sys.stderr.write(
f"generated 0 commands from {source_url} — the document exposes no "
"GET/POST operations OpenBB can map. Multi-file specs that split "
"paths across documents with external $refs must be bundled into "
"a single file first.\n"
)
return 2
write_spec(output_path, spec_doc)
sys.stdout.write(f"wrote {len(spec_doc['commands'])} commands to {output_path}\n")
return 0
@@ -539,6 +547,13 @@ def _generate_extension(
spec_doc = load_spec(spec_path)
original_count = len(spec_doc.get("commands") or {})
if original_count == 0:
sys.stderr.write(
f"--generate-extension: spec at {spec_path} contains 0 commands — "
"nothing to generate. Regenerate the spec against a source that "
"exposes GET/POST operations.\n"
)
return 2
spec_doc = _filter_spec_commands(spec_doc, include=include, exclude=exclude)
filtered_count = len(spec_doc.get("commands") or {})
if (include or exclude) and filtered_count == 0:

View File

@@ -9,6 +9,7 @@ CredentialLocation = Literal["query", "header"]
_CREDENTIAL_NAMES: frozenset[str] = frozenset(
{
"api_key",
"api_token",
"apikey",
"x_api_key",
"x_apikey",

View File

@@ -297,13 +297,38 @@ def _build_root_router(
package_name: str,
root_namespace: str,
sub_routers: list[GeneratedRouter],
root_commands: list[str] | None = None,
root_post_imports: list[tuple[str, str]] | None = None,
root_has_stream: bool = False,
) -> GeneratedRouter:
"""Emit a top-level router that mounts every namespace under ``root_namespace``."""
"""Emit a top-level router that mounts every namespace under ``root_namespace``.
Top-level leaf commands (no namespace of their own) are rendered directly
on this router; unused imports are cleaned up by the ruff pass.
"""
commands = root_commands or []
post_imports = root_post_imports or []
parts: list[str] = [
f'"""Root router for {root_namespace} — generated from spec."""',
"",
"from openbb_core.app.router import Router",
]
if commands:
parts.append("from openbb_core.app.model.command_context import CommandContext")
parts.append("from openbb_core.app.model.obbject import OBBject")
if root_has_stream:
parts.append("from openbb_core.app.model.stream import OBBStream")
parts.append(
"from openbb_core.app.provider_interface import "
"ExtraParams, ProviderChoices, StandardParams"
)
parts.append("from openbb_core.app.query import Query")
parts.append("from openbb_core.app.router import Router")
if post_imports:
parts.append("")
for module_path, function_name in post_imports:
parts.append(
f"from {module_path} import {function_name} as _{function_name}"
)
if sub_routers:
parts.append("")
for r in sub_routers:
@@ -321,6 +346,9 @@ def _build_root_router(
f"router.include_router(_{r.module_name}_router, "
f'prefix="/{r.module_name}")'
)
if commands:
parts.append("")
parts.extend(commands)
return GeneratedRouter(
module_name=root_namespace,
entry_point_name=root_namespace,
@@ -466,6 +494,16 @@ def generate_packages(
f"{package_name}.providers..models.",
f"{package_name}.providers.tools.models.",
)
routers.root_post_imports = [
(
module_path.replace(
f"{package_name}.providers..models.",
f"{package_name}.providers.tools.models.",
),
function_name,
)
for module_path, function_name in routers.root_post_imports
]
root_namespace = _slugify(provider_name or project_slug)
sub_routers = [r for r in routers.routers if r.entry_point_name]
@@ -475,6 +513,9 @@ def generate_packages(
package_name=package_name,
root_namespace=root_namespace,
sub_routers=sub_routers,
root_commands=routers.root_commands,
root_post_imports=routers.root_post_imports,
root_has_stream=routers.root_has_stream,
)
routers.routers.append(root_router)

View File

@@ -40,9 +40,19 @@ class GeneratedRouters:
----------
routers : list of GeneratedRouter
All emitted router modules.
root_commands : list of str
Rendered command blocks for top-level leaf commands; they have no
namespace router of their own and mount directly on the root router.
root_post_imports : list of (str, str)
``(module_path, function_name)`` imports the root command blocks need.
root_has_stream : bool
Whether any root command streams (root router imports ``OBBStream``).
"""
routers: list[GeneratedRouter] = field(default_factory=list)
root_commands: list[str] = field(default_factory=list)
root_post_imports: list[tuple[str, str]] = field(default_factory=list)
root_has_stream: bool = False
def _safe_segment(name: str) -> str:
@@ -107,15 +117,42 @@ def generate_routers(
"""
out = GeneratedRouters()
for top_name, top_node in sorted(root.children.items()):
_emit_router(
top_node,
package_name=package_name,
provider_name=provider_name,
fetchers_by_command=fetchers_by_command,
post_commands_by_command=post_commands_by_command,
collected=out,
is_top_level=True,
)
if top_node.is_namespace:
_emit_router(
top_node,
package_name=package_name,
provider_name=provider_name,
fetchers_by_command=fetchers_by_command,
post_commands_by_command=post_commands_by_command,
collected=out,
is_top_level=True,
)
if top_node.cmd_spec is None:
continue
function_name = _safe_segment(top_name)
description = (top_node.cmd_spec.get("description") or "").strip()
fetcher = fetchers_by_command.get(top_node.full_path)
if fetcher is not None:
renderer = (
_render_stream_command if fetcher.is_streaming else _render_get_command
)
out.root_has_stream = out.root_has_stream or fetcher.is_streaming
out.root_commands.append(
renderer(fetcher, function_name=function_name, description=description)
)
continue
post = post_commands_by_command.get(top_node.full_path)
if post is not None:
out.root_post_imports.append(
(
f"{package_name}.providers.{provider_name}.models."
f"{post.module_name}",
post.function_name,
)
)
out.root_commands.append(
f'router.command(methods=["POST"])(_{post.function_name})\n'
)
return out

View File

@@ -314,12 +314,20 @@ def parse_json_arg(raw: str) -> Any:
def request_body_parameters(
body_schema: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""Flatten a dereferenced request-body schema into OpenAPI parameter objects."""
"""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:
if "object" not in body_types and "properties" not in body_schema:
object_members = [
member
for member in body_schema.get("anyOf", [])
@@ -478,12 +486,16 @@ def detect_api_prefix(spec: dict[str, Any]) -> str:
def url_to_command(url: str, api_prefix: str = "/api/v1") -> str:
"""Convert a URL path to a dotted command, dropping ``{path_params}``."""
"""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) for p in parts]
cleaned = [_strip_placeholders(p).replace(".", "_") for p in parts]
return ".".join(p for p in cleaned if p)
@@ -542,7 +554,11 @@ def build_reference(
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("}"))]
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"):
@@ -732,15 +748,6 @@ def _bundle_external_refs(
documents[url] = parsed
return parsed
def normalize_schema_type(value: dict[str, Any]) -> dict[str, Any]:
"""Convert OpenAPI 3.1 type arrays for the existing code generator."""
schema_types = value.get("type")
if not isinstance(schema_types, list):
return value
normalized = {key: item for key, item in value.items() if key != "type"}
normalized["anyOf"] = [{"type": item} for item in schema_types]
return normalized
def visit(
node: Any,
current_url: str,
@@ -796,21 +803,19 @@ def _bundle_external_refs(
if key != "$ref"
}
if isinstance(resolved, dict):
return normalize_schema_type({**resolved, **siblings})
return {**resolved, **siblings}
return resolved
return normalize_schema_type(
{
key: visit(
value,
current_url,
current_document,
imported=imported,
seen=seen,
depth=depth + 1,
)
for key, value in node.items()
}
)
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(
@@ -907,7 +912,7 @@ def fetch_openapi(
embedded = _extract_embedded_spec(landing.text)
if embedded is not None:
return _bundle_external_refs(
embedded,
_ensure_openapi_dict(embedded, landing_url),
str(getattr(landing, "url", landing_url)),
timeout=timeout,
headers=merged_headers,
@@ -938,4 +943,28 @@ def _ensure_openapi_dict(parsed: Any, source_url: str) -> dict[str, Any]:
"Pass --openapi-path to point at the real spec endpoint "
"(e.g. /swagger/v1/swagger.json)."
)
return parsed
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}

View File

@@ -108,7 +108,7 @@ def _normalize_parameter(
required = bool(param.get("required")) or location == "path"
default = schema.get("default")
lower_choices = {str(c).lower() for c in choices}
if "json" in lower_choices and "xml" in lower_choices:
if "json" in lower_choices and lower_choices & {"xml", "csv"}:
default = "json"
if default is not None:
required = False
@@ -150,6 +150,45 @@ def _normalize_parameter(
}
def _security_parameters(
spec: dict[str, Any], op: dict[str, Any]
) -> list[dict[str, Any]]:
"""Materialize ``apiKey`` security schemes as optional operation parameters.
Auth declared only through ``components.securitySchemes`` (no per-operation
parameter) would otherwise vanish from the spec — codegen and dispatch both
read the parameter list. Per-operation ``security`` overrides the
document-level default, including the explicit-public ``security: []``.
"""
requirements = op.get("security")
if requirements is None:
requirements = spec.get("security") or []
schemes = (spec.get("components") or {}).get("securitySchemes") or {}
out: list[dict[str, Any]] = []
seen: set[str] = set()
for requirement in requirements:
if not isinstance(requirement, dict):
continue
for scheme_name in requirement:
scheme = schemes.get(scheme_name)
if not isinstance(scheme, dict) or scheme.get("type") != "apiKey":
continue
name = scheme.get("name")
location = scheme.get("in", "query")
if not name or name in seen or location not in ("query", "header"):
continue
seen.add(name)
out.append(
{
"name": name,
"in": location,
"schema": {"type": "string"},
"description": scheme.get("description"),
}
)
return out
def _build_operation_entry(
spec: dict[str, Any], url: str, method: str, op: dict[str, Any]
) -> dict[str, Any]:
@@ -164,6 +203,13 @@ def _build_operation_entry(
normalized = _normalize_parameter(resolved, providers_set)
if normalized is not None:
params.append(normalized)
declared = {p["name"] for p in params}
for raw in _security_parameters(spec, op):
if raw["name"] in declared:
continue
normalized = _normalize_parameter(raw)
if normalized is not None:
params.append(normalized)
return {
"url_path": url,
"method": method,