diff --git a/cli/openbb_cli/cli.py b/cli/openbb_cli/cli.py index ee675e8b7db..24934464ff7 100644 --- a/cli/openbb_cli/cli.py +++ b/cli/openbb_cli/cli.py @@ -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: diff --git a/cli/openbb_cli/codegen/credentials.py b/cli/openbb_cli/codegen/credentials.py index 40cdcf76953..9189f07ffb3 100644 --- a/cli/openbb_cli/codegen/credentials.py +++ b/cli/openbb_cli/codegen/credentials.py @@ -9,6 +9,7 @@ CredentialLocation = Literal["query", "header"] _CREDENTIAL_NAMES: frozenset[str] = frozenset( { "api_key", + "api_token", "apikey", "x_api_key", "x_apikey", diff --git a/cli/openbb_cli/codegen/package_gen.py b/cli/openbb_cli/codegen/package_gen.py index 0360e571ef6..efe58eef5fc 100644 --- a/cli/openbb_cli/codegen/package_gen.py +++ b/cli/openbb_cli/codegen/package_gen.py @@ -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) diff --git a/cli/openbb_cli/codegen/router_gen.py b/cli/openbb_cli/codegen/router_gen.py index 02ac839f05b..fbf092abb34 100644 --- a/cli/openbb_cli/codegen/router_gen.py +++ b/cli/openbb_cli/codegen/router_gen.py @@ -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 diff --git a/cli/openbb_cli/dispatchers/openapi_schema.py b/cli/openbb_cli/dispatchers/openapi_schema.py index 726f4b14e10..feb21647a56 100644 --- a/cli/openbb_cli/dispatchers/openapi_schema.py +++ b/cli/openbb_cli/dispatchers/openapi_schema.py @@ -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} diff --git a/cli/openbb_cli/dispatchers/spec.py b/cli/openbb_cli/dispatchers/spec.py index 4a7afcc51a9..2d9df64cea9 100644 --- a/cli/openbb_cli/dispatchers/spec.py +++ b/cli/openbb_cli/dispatchers/spec.py @@ -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, diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index f2884e03245..52bbbdaddf8 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -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 diff --git a/cli/tests/test_codegen_credentials.py b/cli/tests/test_codegen_credentials.py index 2a220c9c997..919580a2b0b 100644 --- a/cli/tests/test_codegen_credentials.py +++ b/cli/tests/test_codegen_credentials.py @@ -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", diff --git a/cli/tests/test_codegen_package_gen.py b/cli/tests/test_codegen_package_gen.py index ed643f138bc..ad8e231f27b 100644 --- a/cli/tests/test_codegen_package_gen.py +++ b/cli/tests/test_codegen_package_gen.py @@ -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", diff --git a/cli/tests/test_codegen_pydantic_gen.py b/cli/tests/test_codegen_pydantic_gen.py index 7e2bdfb8284..947311334f4 100644 --- a/cli/tests/test_codegen_pydantic_gen.py +++ b/cli/tests/test_codegen_pydantic_gen.py @@ -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", diff --git a/cli/tests/test_codegen_router_gen.py b/cli/tests/test_codegen_router_gen.py index 157882caa73..5edff8fad35 100644 --- a/cli/tests/test_codegen_router_gen.py +++ b/cli/tests/test_codegen_router_gen.py @@ -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] diff --git a/cli/tests/test_dispatchers_openapi_schema.py b/cli/tests/test_dispatchers_openapi_schema.py index cf1daea6a90..1e3bf6836fd 100644 --- a/cli/tests/test_dispatchers_openapi_schema.py +++ b/cli/tests/test_dispatchers_openapi_schema.py @@ -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 = "not here" + 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): diff --git a/cli/tests/test_dispatchers_spec.py b/cli/tests/test_dispatchers_spec.py index ea20f506e30..e565bcda84d 100644 --- a/cli/tests/test_dispatchers_spec.py +++ b/cli/tests/test_dispatchers_spec.py @@ -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