mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-05 17:01:58 +00:00
[V5] openbb-cli: Modular OpenAPI Refs (#7589)
* follow up to #7585 * add another codegen test
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -9,6 +9,7 @@ CredentialLocation = Literal["query", "header"]
|
||||
_CREDENTIAL_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"api_token",
|
||||
"apikey",
|
||||
"x_api_key",
|
||||
"x_apikey",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user