diff --git a/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py b/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py index 5bc825a690b..6097f2c65bf 100644 --- a/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py +++ b/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py @@ -385,31 +385,22 @@ class OptionsChainsData(OptionsChainsProperties): core_schema: Any, handler: GetJsonSchemaHandler, ) -> JsonSchemaValue: - """Generate a JSON schema that matches the wire response shape. + """Return a JSON schema that matches serialized row records.""" + _ = (core_schema, handler) - ``OptionsChainsData`` stores data column-oriented (each field is a - ``list[...]``) but the ``model_serialize`` ``@model_serializer`` above - transposes it into a list of row records when serializing. The default - Pydantic schema generated from the field annotations therefore - advertises an object-of-lists, which does not match what FastAPI - actually returns over the wire. - - This override builds a transient row-shaped Pydantic model whose - fields are the inner type of each list field on ``OptionsChainsData`` - (``strike: float | None`` instead of ``strike: list[float]``) and - returns ``{"type": "array", "items": }`` so the OpenAPI - document the API publishes accurately describes the JSON clients - actually receive. - """ row_fields: dict[str, Any] = {} - for fname, finfo in cls.model_fields.items(): - ann = finfo.annotation - inner = get_args(ann)[0] if get_origin(ann) is list else ann - # Datetime fields are stringified by ``model_serialize`` before - # the transpose, so reflect that in the schema as well. - if inner is datetime or get_origin(inner) is None and inner is dateType: + for field_name, field_info in cls.model_fields.items(): + annotation = field_info.annotation + inner = ( + get_args(annotation)[0] + if get_origin(annotation) is list + else annotation + ) + + if inner is datetime or (get_origin(inner) is None and inner is dateType): inner = str | None - row_fields[fname] = ( + + row_fields[field_name] = ( ( inner | None if get_origin(inner) is None and inner is not type(None) @@ -417,27 +408,24 @@ class OptionsChainsData(OptionsChainsProperties): ), Field( default=None, - description=finfo.description, - json_schema_extra=finfo.json_schema_extra, + description=field_info.description, + json_schema_extra=field_info.json_schema_extra, ), ) - row_model = create_model( # type: ignore[call-overload] - f"{cls.__name__}Item", - **row_fields, - ) + row_model = create_model(f"{cls.__name__}Item", **row_fields) # type: ignore[call-overload] items_schema = row_model.model_json_schema( ref_template="#/components/schemas/{model}", ) - # Inline ``$defs`` if Pydantic produced them so the array schema is - # self-contained when embedded in the parent OpenAPI document. defs = items_schema.pop("$defs", None) + schema: JsonSchemaValue = { "type": "array", "description": (cls.__doc__ or "").split("\n\n", maxsplit=1)[0].strip(), "items": items_schema, "title": cls.__name__, } - if defs: # pragma: no cover + if defs: schema["$defs"] = defs + return schema diff --git a/openbb_platform/core/tests/provider/standard_models/test_options_chains.py b/openbb_platform/core/tests/provider/standard_models/test_options_chains.py index 216584ba504..e34d37c38ed 100644 --- a/openbb_platform/core/tests/provider/standard_models/test_options_chains.py +++ b/openbb_platform/core/tests/provider/standard_models/test_options_chains.py @@ -1,19 +1,80 @@ -"""Tests for ``OptionsChainsData`` — pure-Python serializer + OpenAPI schema fidelity. - -These tests intentionally avoid pandas. They exercise the column→row -``@model_serializer`` and the ``__get_pydantic_json_schema__`` override that -keeps the OpenAPI document in sync with the actual JSON wire response. -""" +"""Tests for options chains serialization and schema shape.""" import json from datetime import date, datetime import pytest +from fastapi import FastAPI from openbb_core.app.model.obbject import OBBject from openbb_core.provider.standard_models.options_chains import OptionsChainsData +def _sample_payload() -> dict: + return { + "underlying_symbol": ["FAKE", "FAKE"], + "underlying_price": [100.0, 100.0], + "contract_symbol": ["FAKE240117C00100000", "FAKE240117P00100000"], + "expiration": [date(2030, 1, 17), date(2030, 1, 17)], + "strike": [100.0, 100.0], + "option_type": ["call", "put"], + "open_interest": [10, 5], + "volume": [100, 50], + "bid": [1.5, 1.4], + "ask": [1.6, 1.5], + "last_trade_time": [datetime(2026, 1, 1, 15, 30), datetime(2026, 1, 1, 15, 31)], + } + + +def test_options_chains_model_dump_is_row_records() -> None: + data = OptionsChainsData(**_sample_payload()) + dumped = data.model_dump() + + assert isinstance(dumped, list) + assert len(dumped) == 2 + assert dumped[0]["option_type"] == "call" + assert dumped[1]["option_type"] == "put" + assert isinstance(dumped[0]["last_trade_time"], str) + + +def test_options_chains_json_schema_is_array_of_rows() -> None: + schema = OptionsChainsData.model_json_schema() + + assert schema["type"] == "array" + assert schema["items"]["type"] == "object" + + strike_types = { + entry.get("type") for entry in schema["items"]["properties"]["strike"]["anyOf"] + } + assert "number" in strike_types + assert "null" in strike_types + + +def test_fastapi_openapi_results_references_row_schema() -> None: + app = FastAPI() + + @app.get("/chains") + def chains() -> OBBject[OptionsChainsData]: + return OBBject(results=[]) + + openapi = app.openapi() + components = openapi["components"]["schemas"] + + assert components["OptionsChainsData"]["type"] == "array" + + route_schema = openapi["paths"]["/chains"]["get"]["responses"]["200"]["content"][ + "application/json" + ]["schema"] + wrapper_name = route_schema["$ref"].rsplit("/", 1)[-1] + results_schema = components[wrapper_name]["properties"]["results"] + refs = [ + entry.get("$ref") + for entry in results_schema.get("anyOf", [results_schema]) + if entry.get("$ref") + ] + assert any(ref.endswith("OptionsChainsData") for ref in refs) + + @pytest.fixture def sample_payload() -> dict: """Smallest realistic payload — 2 rows, 1 expiration, 1 strike, call/put."""