[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,

View File

@@ -964,6 +964,54 @@ def test_generate_extension_aborts_when_filter_matches_nothing(tmp_path, capsys)
assert "matched no commands" in err
def test_generate_spec_errors_when_zero_commands(tmp_path, capsys):
"""A source that maps to no commands is a loud error, not a skeleton spec."""
with patch(
"openbb_cli.dispatchers.openapi_schema.fetch_openapi",
return_value={
"openapi": "3.1.0",
"paths": {"/x": {"$ref": "./paths/x.yaml#/~1x"}},
},
):
rc = cli._generate_spec(
"http://localhost:8000",
str(tmp_path / "x.spec"),
"/openapi.yaml",
)
assert rc == 2
err = capsys.readouterr().err
assert "0 commands" in err
assert "external $refs" in err
assert not (tmp_path / "x.spec").exists()
def test_generate_extension_aborts_when_spec_has_zero_commands(tmp_path, capsys):
"""An empty spec never reaches codegen — no zero-command skeleton package."""
from openbb_cli.dispatchers.spec import SPEC_VERSION, write_spec
spec_path = tmp_path / "empty.spec"
write_spec(
spec_path,
{
"version": SPEC_VERSION,
"base_url": "http://x",
"api_prefix": "/api/v1",
"commands": {},
},
)
rc = cli._generate_extension(
[(None, str(spec_path))],
str(tmp_path / "out"),
provider_name=None,
project_name=None,
package_name=None,
router_name=None,
)
assert rc == 2
assert "0 commands" in capsys.readouterr().err
assert not (tmp_path / "out").exists()
def test_generate_extension_filters_commands_before_codegen(tmp_path, capsys):
"""``--include`` reaches all the way through to ``generate_packages``:
the filtered spec is what codegen actually sees, so the emitted

View File

@@ -32,6 +32,8 @@ def test_normalize_credential_key_canonicalizes_separators_and_case(raw, expecte
"name",
[
"api_key",
"api_token",
"api-token",
"apikey",
"API_KEY",
"X-API-Key",

View File

@@ -83,6 +83,39 @@ def test_generate_packages_returns_single_package_with_provider_and_router(tmp_p
assert package.fetchers_by_provider["fmp"][0].model_name == "EquitySearch"
def test_generate_packages_top_level_command_mounts_on_root_router(tmp_path):
spec = {
"base_url": "https://api.example.com",
"api_prefix": "",
"commands": {
"eod": {
"providers": ["eodhd"],
"method": "get",
"url_path": "/eod/{ticker}",
"description": "End of day.",
"parameters": [
{
"name": "ticker",
"in": "path",
"type": "string",
"required": True,
}
],
"response_schema": {"type": "object"},
}
},
}
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="eodhd")
package = out.packages[0]
by_module = {r.module_name: r for r in package.routers.routers}
# No dedicated (empty) router module for the top-level command
assert "eod" not in by_module
root = by_module["eodhd"]
assert '@router.command(model="Eod")' in root.source
assert "async def eod(" in root.source
assert "from openbb_core.app.query import Query" in root.source
def test_generate_packages_skips_coverage_namespace(tmp_path):
spec = {
"base_url": "https://api.example.com",

View File

@@ -403,6 +403,31 @@ def test_generate_class_required_and_optional_fields():
assert "label: str | None = Field(default=None, description='Pretty label.')" in src
def test_generate_class_normalized_nullable_fields_keep_format_and_enum():
"""The 3.1 chain end-to-end: ``expand_type_arrays`` copies siblings into
``anyOf`` variants, so nullable date/enum fields type as ``datetime.date``
/ ``Literal`` — not ``str``. Guards against a bare-variant normalizer
(one that emits ``{"type": t}`` only) ever reappearing upstream."""
from openbb_cli.dispatchers.openapi_schema import expand_type_arrays
schema = expand_type_arrays(
{
"type": "object",
"properties": {
"date": {"type": ["string", "null"], "format": "date"},
"rating": {"type": ["string", "null"], "enum": ["AAA", "AA"]},
"close": {"type": ["number", "null"]},
},
}
)
cls = pg.generate_class(schema, class_name="Row")
src = cls.source
assert "date: datetime.date | None" in src
assert "rating: Literal['AAA', 'AA'] | None" in src
assert "close: float | None" in src
assert "import datetime" in cls.collect_imports()
def test_generate_class_field_alias_for_dotted_property_name():
schema = {
"type": "object",

View File

@@ -244,3 +244,124 @@ def test_generate_routers_skips_command_without_matching_fetcher_or_post():
assert "async def" not in src
assert "router.command" not in src
ast.parse(src)
# --- top-level leaf commands land on the root router ---
def test_generate_routers_top_level_command_becomes_root_command():
tree = nt.build_namespace_tree(
{"eod": {"providers": ["eodhd"], "description": "End of day."}}
)
fetchers = {
"eod": _Fetcher(
module_name="eod",
fetcher_class="EodFetcher",
model_name="Eod",
)
}
out = rg.generate_routers(
tree,
package_name="openbb_codegen",
provider_name="eodhd",
fetchers_by_command=fetchers,
post_commands_by_command={},
)
# No dedicated router module for a pure top-level command
assert out.routers == []
assert len(out.root_commands) == 1
block = out.root_commands[0]
assert '@router.command(model="Eod")' in block
assert "async def eod(" in block
assert out.root_has_stream is False
def test_generate_routers_top_level_stream_command_sets_flag():
tree = nt.build_namespace_tree(
{"feed": {"providers": ["wargame"], "description": "Live feed."}}
)
fetchers = {
"feed": _Fetcher(
module_name="feed",
fetcher_class="FeedFetcher",
model_name="Feed",
is_streaming=True,
)
}
out = rg.generate_routers(
tree,
package_name="openbb_codegen",
provider_name="wargame",
fetchers_by_command=fetchers,
post_commands_by_command={},
)
assert out.root_has_stream is True
assert "OBBStream" in out.root_commands[0]
def test_generate_routers_top_level_post_command_records_import():
tree = nt.build_namespace_tree(
{"regression": {"providers": [], "description": "Linear fit."}}
)
posts = {
"regression": _PostCommand(
module_name="regression",
function_name="regression",
)
}
out = rg.generate_routers(
tree,
package_name="openbb_codegen",
provider_name="tools",
fetchers_by_command={},
post_commands_by_command=posts,
)
assert out.root_post_imports == [
("openbb_codegen.providers.tools.models.regression", "regression")
]
assert 'router.command(methods=["POST"])(_regression)' in out.root_commands[0]
def test_generate_routers_top_level_command_without_fetcher_is_dropped():
tree = nt.build_namespace_tree(
{"orphan": {"providers": ["fmp"], "description": "No fetcher."}}
)
out = rg.generate_routers(
tree,
package_name="openbb_codegen",
provider_name="fmp",
fetchers_by_command={},
post_commands_by_command={},
)
assert out.routers == []
assert out.root_commands == []
def test_generate_routers_mixes_namespaces_and_root_commands():
tree = nt.build_namespace_tree(
{
"eod": {"providers": ["eodhd"], "description": "End of day."},
"calendar.ipos": {"providers": ["eodhd"], "description": "IPOs."},
}
)
fetchers = {
"eod": _Fetcher(
module_name="eod", fetcher_class="EodFetcher", model_name="Eod"
),
"calendar.ipos": _Fetcher(
module_name="calendar_ipos",
fetcher_class="CalendarIposFetcher",
model_name="CalendarIpos",
),
}
out = rg.generate_routers(
tree,
package_name="openbb_codegen",
provider_name="eodhd",
fetchers_by_command=fetchers,
post_commands_by_command={},
)
assert [r.module_name for r in out.routers] == ["calendar"]
assert "async def ipos(" in out.routers[0].source
assert len(out.root_commands) == 1
assert "async def eod(" in out.root_commands[0]

View File

@@ -21,6 +21,7 @@ from openbb_cli.dispatchers.openapi_schema import (
build_parser_from_operation,
build_reference,
build_router_map,
expand_type_arrays,
parameter_to_kwargs,
parse_json_arg,
request_body_parameters,
@@ -1461,6 +1462,186 @@ def test_fetch_openapi_rejects_non_dict_body(monkeypatch):
openapi_schema.fetch_openapi("http://h", path="/swagger/v1/swagger.json")
# --- expand_type_arrays (OpenAPI 3.1 type arrays → anyOf unions) ---
def test_expand_type_arrays_nullable_scalar_becomes_anyof():
out = expand_type_arrays({"type": ["number", "null"], "description": "px"})
assert "type" not in out
assert out["description"] == "px"
assert out["anyOf"] == [
{"type": "number", "description": "px"},
{"type": "null"},
]
def test_expand_type_arrays_copies_structural_siblings_into_variants():
out = expand_type_arrays(
{"type": ["string", "null"], "format": "date", "enum": ["a", None]}
)
non_null = [v for v in out["anyOf"] if v.get("type") != "null"]
assert non_null == [{"type": "string", "format": "date", "enum": ["a", None]}]
def test_expand_type_arrays_multi_type_union_keeps_every_variant():
out = expand_type_arrays({"type": ["string", "number", "integer", "null"]})
assert [v["type"] for v in out["anyOf"]] == [
"string",
"number",
"integer",
"null",
]
def test_expand_type_arrays_leaves_existing_combinators_alone():
node = {"type": ["string", "null"], "anyOf": [{"type": "string"}]}
assert expand_type_arrays(node) == node
one_of = {"type": ["string", "null"], "oneOf": [{"type": "string"}]}
assert expand_type_arrays(one_of) == one_of
def test_expand_type_arrays_recurses_into_nested_schemas():
doc = {
"paths": {
"/x": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"close": {"type": ["number", "null"]}
},
},
}
}
}
}
}
}
}
}
}
out = expand_type_arrays(doc)
close = out["paths"]["/x"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"]["items"]["properties"]["close"]
assert close == {"anyOf": [{"type": "number"}, {"type": "null"}]}
def test_expand_type_arrays_passthrough_for_scalars_and_plain_types():
assert expand_type_arrays("x") == "x"
assert expand_type_arrays({"type": "string"}) == {"type": "string"}
assert expand_type_arrays([{"type": ["integer", "null"]}]) == [
{"anyOf": [{"type": "integer"}, {"type": "null"}]}
]
def test_request_body_parameters_accepts_normalized_nullable_object():
"""A 3.1 nullable-object body still yields body params after normalization."""
body = expand_type_arrays(
{
"type": ["object", "null"],
"required": ["symbol"],
"properties": {"symbol": {"type": "string"}},
}
)
assert "type" not in body # moved into anyOf variants
params = request_body_parameters(body)
assert [p["name"] for p in params] == ["symbol"]
assert params[0]["required"] is True
def test_fetch_openapi_normalizes_type_arrays(monkeypatch):
from openbb_cli.dispatchers import openapi_schema
class _R:
status_code = 200
text = (
'{"openapi": "3.1.0", "paths": {"/x": {"get": {"responses": {"200": '
'{"content": {"application/json": {"schema": '
'{"type": ["number", "null"]}}}}}}}}}'
)
headers = {"content-type": "application/json"}
def raise_for_status(self):
return None
monkeypatch.setattr(openapi_schema.httpx, "get", lambda *a, **k: _R())
spec = openapi_schema.fetch_openapi("https://api.example.com")
schema = spec["paths"]["/x"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"]
assert schema == {"anyOf": [{"type": "number"}, {"type": "null"}]}
def test_fetch_openapi_normalizes_type_arrays_in_embedded_spec(monkeypatch):
from openbb_cli.dispatchers import openapi_schema
class _R:
status_code = 404
text = "<html>not here</html>"
headers = {"content-type": "text/html"}
def raise_for_status(self):
raise openapi_schema.httpx.HTTPStatusError(
"404", request=None, response=None
)
class _Landing:
status_code = 200
text = (
'window.spec = {"openapi": "3.1.0", "paths": {"/x": {"get": '
'{"responses": {"200": {"content": {"application/json": {"schema": '
'{"type": ["string", "null"]}}}}}}}}};'
)
headers = {"content-type": "text/html"}
def raise_for_status(self):
return None
monkeypatch.setattr(
openapi_schema.httpx,
"get",
lambda url, **k: _R() if "openapi.json" in url else _Landing(),
)
spec = openapi_schema.fetch_openapi("https://h.example.com")
schema = spec["paths"]["/x"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"]
assert schema == {"anyOf": [{"type": "string"}, {"type": "null"}]}
# --- literal dots inside URL segments ---
def test_url_to_command_sanitizes_literal_dots_in_segments():
assert (
url_to_command("/v1.1/fundamentals/{ticker}", api_prefix="")
== "v1_1.fundamentals"
)
def test_build_reference_sanitizes_dotted_segments_to_match_router_map():
spec = {
"paths": {
"/v1.1/fundamentals/{ticker}": {"get": {"summary": "F"}},
"/v1.1/bulk": {"get": {"summary": "B"}},
}
}
ref = build_reference(spec, api_prefix="")
assert "/v1_1/fundamentals" in ref["paths"]
assert "/v1_1/bulk" in ref["paths"]
assert "/v1_1/" in ref["routers"]
# --- _bundle_external_refs (same-origin modular spec resolution) ---
def test_bundle_external_refs_resolves_nested_same_origin_documents(monkeypatch):
from openbb_cli.dispatchers import openapi_schema
@@ -1513,7 +1694,13 @@ def test_bundle_external_refs_resolves_nested_same_origin_documents(monkeypatch)
schema = bundled["paths"]["/items"]["post"]["requestBody"]["content"][
"application/json"
]["schema"]
assert schema["anyOf"] == [{"type": "object"}, {"type": "null"}]
# ``_ensure_openapi_dict`` normalizes each loaded document, so the 3.1
# type array arrives as sibling-preserving anyOf variants.
assert schema["anyOf"] == [
{"type": "object", "properties": {"symbol": {"type": "string"}}},
{"type": "null"},
]
assert schema["properties"] == {"symbol": {"type": "string"}}
assert request_body_parameters(schema)[0]["name"] == "symbol"
@@ -1637,15 +1824,17 @@ def test_bundle_external_refs_rejects_resource_identifiers():
)
def test_bundle_external_refs_preserves_non_nullable_type_unions():
def test_bundle_external_refs_leaves_type_arrays_to_ingestion_normalization():
"""Bundling only inlines refs — 3.1 type arrays are ``expand_type_arrays``'
job inside ``_ensure_openapi_dict``, which every fetched document passes
through before bundling."""
bundled = _bundle_external_refs(
{"openapi": "3.1.0", "type": ["integer", "number"], "paths": {}},
"https://api.example/openapi.json",
timeout=1,
headers={},
)
assert bundled["anyOf"] == [{"type": "integer"}, {"type": "number"}]
assert bundled["type"] == ["integer", "number"]
def test_bundle_external_refs_rejects_schema_ref_siblings(monkeypatch):

View File

@@ -1175,6 +1175,123 @@ def test_normalize_parameter_xml_default_flips_to_json():
assert out["default"] == "json"
def test_normalize_parameter_csv_default_flips_to_json():
"""When ``csv`` and ``json`` are both offered, default flips to ``json``."""
out = _normalize_parameter(
{
"name": "fmt",
"schema": {"type": "string", "enum": ["json", "csv"]},
}
)
assert out["default"] == "json"
assert out["required"] is False
def test_normalize_parameter_json_only_enum_keeps_no_default():
"""A lone ``json`` choice is not a format toggle — no default injected."""
out = _normalize_parameter(
{
"name": "fmt",
"schema": {"type": "string", "enum": ["json"]},
}
)
assert out["default"] is None
# --- securitySchemes → synthetic credential parameters ---
def _apikey_spec(**op_extra):
return {
"security": [{"KeyAuth": []}],
"components": {
"securitySchemes": {
"KeyAuth": {
"type": "apiKey",
"in": "query",
"name": "api_token",
"description": "API key.",
}
}
},
"paths": {"/data": {"get": {"summary": "Data", **op_extra}}},
}
def test_build_command_spec_materializes_global_apikey_as_optional_param():
commands = build_command_spec(_apikey_spec(), api_prefix="")
params = {p["name"]: p for p in commands["data"]["parameters"]}
assert "api_token" in params
assert params["api_token"]["in"] == "query"
assert params["api_token"]["required"] is False
assert params["api_token"]["help"] == "API key."
def test_build_command_spec_operation_security_empty_suppresses_global():
commands = build_command_spec(_apikey_spec(security=[]), api_prefix="")
names = [p["name"] for p in commands["data"]["parameters"]]
assert "api_token" not in names
def test_build_command_spec_declared_param_not_duplicated_by_security():
spec = _apikey_spec(
parameters=[{"name": "api_token", "in": "query", "schema": {"type": "string"}}]
)
commands = build_command_spec(spec, api_prefix="")
names = [p["name"] for p in commands["data"]["parameters"]]
assert names.count("api_token") == 1
def test_security_parameters_header_scheme_and_non_apikey():
from openbb_cli.dispatchers.spec import _security_parameters
spec = {
"security": [{"HeaderAuth": []}, {"Bearer": []}, {"CookieAuth": []}],
"components": {
"securitySchemes": {
"HeaderAuth": {"type": "apiKey", "in": "header", "name": "X-Api-Key"},
"Bearer": {"type": "http", "scheme": "bearer"},
"CookieAuth": {"type": "apiKey", "in": "cookie", "name": "sid"},
}
},
}
out = _security_parameters(spec, {})
assert out == [
{
"name": "X-Api-Key",
"in": "header",
"schema": {"type": "string"},
"description": None,
}
]
def test_security_parameters_dedupes_repeated_scheme_name():
from openbb_cli.dispatchers.spec import _security_parameters
spec = {
"security": [{"KeyAuth": []}, {"KeyAuth": []}],
"components": {
"securitySchemes": {
"KeyAuth": {"type": "apiKey", "in": "query", "name": "api_token"}
}
},
}
assert len(_security_parameters(spec, {})) == 1
def test_security_parameters_skips_malformed_requirements():
from openbb_cli.dispatchers.spec import _security_parameters
spec = {
"security": ["not-a-dict", {"Missing": []}, {"NoName": []}],
"components": {
"securitySchemes": {"NoName": {"type": "apiKey", "in": "query"}}
},
}
assert _security_parameters(spec, {}) == []
def test_operation_providers_skips_non_dict_param_entries():
"""Defensive: a malformed parameter entry doesn't crash provider extraction."""
from openbb_cli.dispatchers.spec import _operation_providers