From f84ac7647c9b60a6978cfc7a0b0da3cf2007bbb0 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Thu, 4 Sep 2025 20:09:40 -0700 Subject: [PATCH] [Feature] `openbb-platform-api` Better POST Params Discovery (#7204) * better post params discovery * missing sentence in readme * nasdaq expected widgets * codespell * openapi_extra is expected to be not None by packagebuilder * lint * lint --- .../economy/openbb_economy/economy_router.py | 12 +- .../extensions/platform_api/README.md | 34 +- .../openbb_platform_api/query_models.py | 10 - .../openbb_platform_api/utils/openapi.py | 115 +++++-- .../openbb_platform_api/utils/widgets.py | 316 +++++++++++------- .../platform_api/tests/mock_openapi.json | 7 +- .../platform_api/tests/mock_widgets.json | 122 ++++--- .../platform_api/tests/test_openapi_utils.py | 22 +- .../platform_api/tests/test_widgets_utils.py | 6 +- .../nasdaq/tests/record/expected_widgets.json | 13 +- 10 files changed, 409 insertions(+), 248 deletions(-) diff --git a/openbb_platform/extensions/economy/openbb_economy/economy_router.py b/openbb_platform/extensions/economy/openbb_economy/economy_router.py index eceae02bbd9..8e1a6c648ad 100644 --- a/openbb_platform/extensions/economy/openbb_economy/economy_router.py +++ b/openbb_platform/extensions/economy/openbb_economy/economy_router.py @@ -870,7 +870,7 @@ async def direction_of_trade( "h": 27, }, "refetchInterval": False, - "endpoint": "/api/v1/economy/fomc_documents/download", + "endpoint": f"{api_prefix}/economy/fomc_documents/download", "params": [ { "type": "endpoint", @@ -915,7 +915,7 @@ async def fomc_documents( """ results = await OBBject.from_query(Query(**locals())) - return results.results.content + return results.results.content # type: ignore # This endpoint is used to download FOMC documents in Workspace. @@ -926,11 +926,7 @@ async def fomc_documents( @router._api_router.post( "/fomc_documents/download", include_in_schema=False, - openapi_extra={ - "widget_config": { - "exclude": True, - } - }, + openapi_extra={}, ) async def fomc_documents_download(params: Annotated[dict, Body()]) -> list: """ @@ -947,7 +943,7 @@ async def fomc_documents_download(params: Annotated[dict, Body()]) -> list: urls = params.get("url", []) - results = [] + results: list = [] for url in urls: try: response = make_request(url) diff --git a/openbb_platform/extensions/platform_api/README.md b/openbb_platform/extensions/platform_api/README.md index 6aed5636b23..3f6e27bb8de 100644 --- a/openbb_platform/extensions/platform_api/README.md +++ b/openbb_platform/extensions/platform_api/README.md @@ -433,10 +433,12 @@ For example, the response to submitting a form can be a Markdown widget with a c The entry in `widgets.json` will be automatically created if the conditions below are met: -- GET and POST methods must share the same API route. +- GET request defines in top-level `widget_config`: + - `{"form_endpoint": /path_to/form_post_endpoint}` - POST method takes 1 positional argument, a sub-class of Pydantic BaseModel. - Create a model, like annotated table fields, defining all inputs to the form. + #### Example The code below creates a widget with a form as the input, and an output table of all submitted forms, as processed through the `IntakeForm` model. @@ -447,15 +449,14 @@ from datetime import date as dateType from typing import Literal, Union # from fastapi import FastAPI -from openbb_platform_api.query_models import FormData from openbb_platform_api.response_models import Data -from pydantic import ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field # app = FastAPI() AccountTypes = Literal["General Fund", "Separately Managed", "Private Equity", "Family Office"] -class GeneralIntake(FormData): +class GeneralIntake(BaseModel): """Submit a form via POST request.""" date_created: dateType = Field( @@ -476,7 +477,11 @@ class GeneralIntake(FormData): submit: bool = Field( default=True, title="Submit", - type="button", # This creates a button, when pressed the parameter is sent as True + json_schema_extra={ + "x-widget_config": { + "type": "button", + }, + } ) @@ -510,7 +515,7 @@ class IntakeForm(Data): INTAKE_FORMS: list[IntakeForm] = [] -@app.post("/general_intake") +@app.post("/general_intake_submit") async def general_intake_post(data: GeneralIntake) -> bool: global INTAKE_FORMS try: @@ -520,7 +525,14 @@ async def general_intake_post(data: GeneralIntake) -> bool: raise e from e -@app.get("/general_intake") +@app.get( + "/general_intake", + openapi_extra= { + "widget_config": { + "form_endpoint": "/general_intake_submit", + }, + }, +) async def general_intake() -> list[IntakeForm]: return INTAKE_FORMS ``` @@ -529,6 +541,12 @@ async def general_intake() -> list[IntakeForm]: ### Omni Widget Example +An Omni Widget is a POST request where all parameters are sent to the request body, along with the text input box (keyed as "prompt"). + +The returned type can be a list of records (table), a Plotly Figure, or formatted Markdwon. +The model will attempt to assign the correct return type dynamically. + +Set the response model as `OmniWidgetResponseModel`, then return `{"content": your_content}` from the endpoint. ```python from typing import Literal, Optional @@ -565,7 +583,7 @@ async def create_omni_widget(item: TestOmniWidgetQueryModel): if item.parse_as == "chart": some_test_data = { "data": [{"type": "bar", "x": ["A", "B", "C"], "y": [1, 2, 3]}], - "layout": {"template": "...", "title": {"text": "Hello Chart!"}} + "layout": {"template": "plotly_dark", "title": {"text": "Hello Chart!"}} } elif item.parse_as == "text": some_test_data = f""" diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/query_models.py b/openbb_platform/extensions/platform_api/openbb_platform_api/query_models.py index ef36bbb86d0..bb09bead2bb 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/query_models.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/query_models.py @@ -7,16 +7,6 @@ from pydantic import AliasGenerator, ConfigDict, Field, field_validator from pydantic.alias_generators import to_snake -class FormData(Data): - """Submit a form via POST request.""" - - model_config = ConfigDict( - extra="allow", - alias_generator=AliasGenerator(to_snake), - title="Submit Form", - ) - - class OmniWidgetInput(Data): """Input for OmniWidget.""" diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/utils/openapi.py b/openbb_platform/extensions/platform_api/openbb_platform_api/utils/openapi.py index c06ca55b5f4..3b4312a4e35 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/utils/openapi.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/utils/openapi.py @@ -1,5 +1,8 @@ """OpenAPI parsing Utils.""" +# pylint: disable=C0302,R0912 +# flake8: noqa: PLR0912 + from typing import Optional from openbb_core.provider.utils.helpers import to_snake_case @@ -937,39 +940,93 @@ def post_query_schema_for_widget( ): # Get the reference to the schema for the request body. - param_ref = ( - schema["items"].get("$ref") - if "items" in schema - else schema.get("$ref") or schema - ) + title = schema.get("title") + providers: list[str] = [] - if isinstance(param_ref, dict) and "type" in param_ref: - param_ref = param_ref["type"] + if title and title in schema: + providers = [title] + elif title and "," in title: + providers = title.split(",") + else: + providers = ["Custom"] - if param_ref and isinstance(param_ref, str): - # Extract the schema name from the reference - schema_name = param_ref.split("/")[-1] - schema = openapi_json["components"]["schemas"].get(schema_name, schema_name) - props = {} if isinstance(schema, str) else schema.get("properties", {}) - - for k, v in props.items(): - if target_schema and target_schema != k: - continue - if nested_schema := v.get("$ref"): - nested_schema_name = nested_schema.split("/")[-1] - nested_schema = openapi_json["components"]["schemas"].get( - nested_schema_name, {} - ) - for nested_k, nested_v in nested_schema.get( - "properties", {} - ).items(): - set_param(nested_k, nested_v) - - else: + if params := _route.get("parameters"): + if isinstance(params, list): + for _param in params: + set_param(_param["name"], _param["schema"]) + elif isinstance(params, dict): + for k, v in params.items(): set_param(k, v) - route_params: list[dict] = [] - providers = ["custom"] + if "items" in schema or "$ref" in schema: + param_ref = ( + schema["items"].get("$ref") + if "items" in schema + else schema.get("$ref") or schema + ) + + if isinstance(param_ref, dict) and "type" in param_ref: + param_ref = param_ref["type"] + + if param_ref and isinstance(param_ref, str): + # Extract the schema name from the reference + schema_name = param_ref.split("/")[-1] + schema = openapi_json["components"]["schemas"].get( + schema_name, schema_name + ) + props = {} if isinstance(schema, str) else schema.get("properties", {}) + + for k, v in props.items(): + if target_schema and target_schema != k: + continue + if nested_schema := v.get("$ref"): + nested_schema_name = nested_schema.split("/")[-1] + nested_schema = openapi_json["components"]["schemas"].get( + nested_schema_name, {} + ) + for nested_k, nested_v in nested_schema.get( + "properties", {} + ).items(): + set_param(nested_k, nested_v) + + else: + set_param(k, v) + + route_params: list[dict] = [] + + for new_param_values in new_params.values(): + _new_values = new_param_values.copy() + p = process_parameter(_new_values, providers) + if not p.get("exclude") and not p.get("x-widget_config", {}).get( + "exclude" + ): + route_params.append(p) + + return route_params + if "anyOf" in _route or "anyOf" in schema: + any_of_schema = ( + schema.get("anyOf", []) + if "anyOf" in schema + else _route.get("anyOf", []) + ) + for item in any_of_schema: + # If item is a $ref, resolve it + if "$ref" in item: + ref_name = item["$ref"].split("/")[-1] + ref_schema = openapi_json["components"]["schemas"].get(ref_name, {}) + if "properties" in ref_schema: + for k, v in ref_schema["properties"].items(): + if target_schema and target_schema != k: + continue + set_param(k, v) + # If item has properties directly + elif "properties" in item: + for k, v in item["properties"].items(): + if target_schema and target_schema != k: + continue + set_param(k, v) + + route_params = [] for new_param_values in new_params.values(): _new_values = new_param_values.copy() diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/utils/widgets.py b/openbb_platform/extensions/platform_api/openbb_platform_api/utils/widgets.py index 4d5e8ebe7e8..8a3cb7503d3 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/utils/widgets.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/utils/widgets.py @@ -239,6 +239,16 @@ def modify_query_schema(query_schema: list[dict], provider_value: str): return modified_query_schema +def get_form_input_paths(openapi: dict) -> dict: + """Get a mapping of form input paths, defined by 'widget_config.form_endpoint'.""" + return { + path: config.get("form_endpoint") + for path, path_config in openapi["paths"].items() + if (config := path_config.get("get", {}).get("widget_config")) + and config.get("form_endpoint") + } + + def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many-locals, too-many-statements openapi: dict, widget_exclude_filter: list ): @@ -261,6 +271,8 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- starred_list.append(item) widget_exclude_filter.remove(item) + # Collect all routes that are designated as form endpoints to exclude them from direct widget generation + form_endpoint_paths = get_form_input_paths(openapi) widgets_json: dict = {} routes = [ p @@ -269,13 +281,20 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- and ("get" in openapi["paths"][p] or "post" in openapi["paths"][p]) ] for route in routes: - has_form_endpoint = False - form_route: dict = {} - if "post" in openapi["paths"][route] and "get" in openapi["paths"][route]: - has_form_endpoint = True - form_route = openapi["paths"][route]["post"] - elif "post" in openapi["paths"][route]: - form_route = openapi["paths"][route]["post"] + # Skip routes that are only used as form endpoints for other routes + if route in form_endpoint_paths.values(): + continue + + route_api = openapi["paths"][route] + + has_form_endpoint = route in list(form_endpoint_paths) + form_endpoint_path = form_endpoint_paths.get(route) if has_form_endpoint else "" + form_route: dict = ( + openapi["paths"][form_endpoint_path]["post"] if has_form_endpoint else {} + ) + # Determine the primary method for the widget + # If a GET exists, it's the primary. Otherwise, it's a POST. + route_method = "get" if "get" in route_api else "post" skip = False for starred in starred_list: @@ -292,10 +311,6 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- if skip is True: continue - route_api = openapi["paths"][route] - route_method = ( - "post" if "post" in route_api and not has_form_endpoint else "get" - ) widget_id = ( ( route[1:].replace("/", "_") @@ -318,7 +333,7 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- # Prepare the query schema of the widget if route_method == "get": query_schema, has_chart = get_query_schema_for_widget(openapi, route) - else: + else: # post query_schema = ( post_query_schema_for_widget( openapi, route_api.get("post", {}).get("operationId", ""), route @@ -349,7 +364,7 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- columns_defs = ( data_schema_to_columns_defs(openapi, widget_id, provider, route) if widget_config_dict.get("type") - not in ["multi_file_viewer", "pdf", "metric", "omni"] + not in ["multi_file_viewer", "pdf", "metric"] else [] ) _cats = [ @@ -383,121 +398,182 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- param_names: list = [] var_schema: dict = {} - if ( - _schema := route_api.get("post", {}) - .get("requestBody", {}) - .get("content", {}) - .get("application/json", {}) - .get("schema", {}) - ): - schema_name = _schema.get("$ref", "").split("/")[-1] - var_schema = openapi["components"]["schemas"].get(schema_name, {}) - if var_schema: - var_props = var_schema.get("properties", {}) - for k, v in var_props.items(): - if "$ref" in v: - param_names.append(k) + # Determine the source of the POST body schema + post_body_source = None + if has_form_endpoint: + post_body_source = form_route + elif route_method == "post": + post_body_source = route_api.get("post") - if param_names: - for _param in param_names: + if post_body_source: + if ( + _schema := post_body_source.get("requestBody", {}) + .get("content", {}) + .get("application/json", {}) + .get("schema", {}) + ): + schema_name = _schema.get("$ref", "").split("/")[-1] + var_schema = openapi["components"]["schemas"].get(schema_name, {}) + + if var_schema: + var_props = var_schema.get("properties", {}) + for k, v in var_props.items(): + if "$ref" in v: + param_names.append(k) + + if param_names: + for _param in param_names: + post_params = post_query_schema_for_widget( + openapi, post_body_source.get("operationId"), route, _param + ) + modified_post_params = modify_query_schema( + post_params, provider # type: ignore + ) + + if has_form_endpoint: + has_submit = False + for item in modified_post_params: + if item.get("type") == "button": + has_submit = True + break + + if not has_submit: + modified_post_params.append( + { + "paramName": "submit", + "label": "Submit", + "type": "button", + "value": True, + "description": "Submit the form.", + } + ) + + form_params = { + "type": "form", + "paramName": _param, + "label": "Form", + "description": "Form Data", + "endpoint": form_endpoint_path, + "inputParams": modified_post_params, + } + + if post_config := var_schema.get("x-widget_config", {}): + form_params = deep_merge_configs( + form_params, + post_config, + ) + + modified_query_schema.append(form_params) + else: + # For non-form endpoints, extend with the modified params directly + modified_query_schema.extend(modified_post_params) + else: # This handles POST requests with no parameters in the body post_params = post_query_schema_for_widget( - openapi, form_route.get("operationId"), route, _param + openapi, + post_body_source.get("operationId"), + form_endpoint_path if has_form_endpoint else route, ) modified_post_params = modify_query_schema( post_params, provider # type: ignore ) if has_form_endpoint: + + has_submit = False + for item in modified_post_params: + if item.get("type") == "button": + has_submit = True + break + + if not has_submit: + modified_post_params.append( + { + "paramName": "submit", + "label": "Submit", + "value": True, + "type": "button", + "description": "Submit the form.", + } + ) + form_params = { "type": "form", - "paramName": _param, - "label": "Form", - "description": "Form Data", - "endpoint": ( - route.replace("/api", "api") - if "/api" in route - else route[1:] if route[0] == "/" else route - ), + "paramName": "form", + "label": var_schema.get("title", "Form"), + "description": var_schema.get("description", ""), + "endpoint": form_endpoint_path, "inputParams": modified_post_params, } + var_key: dict = {} + # Widget Config at the model level goes first. if post_config := var_schema.get("x-widget_config", {}): + for key, value in post_config.copy().items(): + if key.startswith("$."): + var_key[key] = value + else: + form_params[key] = value + form_params = deep_merge_configs( form_params, post_config, ) + # Then the widget config at the POST endpoint level takes priority. + if post_config := form_route.get("widget_config", {}): + for key, value in post_config.copy().items(): + if key.startswith("$."): + var_key[key] = value + + form_params = deep_merge_configs( + form_params, + { + k: v + for k, v in post_config.items() + if not k.startswith("$.") + }, + ) + modified_query_schema.append(form_params) - else: - # For non-form endpoints, extend with the modified params directly - modified_query_schema.extend(modified_post_params) - else: - post_params = post_query_schema_for_widget( - openapi, form_route.get("operationId"), route - ) - modified_post_params = modify_query_schema(post_params, provider) # type: ignore - if has_form_endpoint: - form_params = { - "type": "form", - "paramName": "", - "label": var_schema.get("title", "Form"), - "description": var_schema.get("description", ""), - "endpoint": ( - route.replace("/api", "api") - if "/api" in route - else route[1:] if route[0] == "/" else route - ), - "inputParams": modified_post_params, - } + if var_key: + for key, value in var_key.items(): + if ( + key.replace("$.", "") in widget_config_dict + and "params" not in key + and "inputParams" not in key + ): + widget_config_dict.update( + {key.replace("$.", ""): value} + ) + else: + widget_config_dict[key.replace("$.", "")] = value + # For POST-only routes, extend with the modified params directly + # elif route_method == "post": + # modified_query_schema.extend(modified_post_params) + elif route_method == "post": - var_key: dict = {} - # Widget Config at the model level goes first. - if post_config := var_schema.get("x-widget_config", {}): - for key, value in post_config.copy().items(): - if key.startswith("$."): - var_key[key] = value - else: - form_params[key] = value + var_key = {} + # Widget Config at the model level goes first. + if post_config := var_schema.get("x-widget_config", {}): + for key, value in post_config.copy().items(): + if key.startswith("$."): + var_key[key] = value - form_params = deep_merge_configs( - form_params, - post_config, - ) + if var_key: + for key, value in var_key.items(): + if ( + key.replace("$.", "") in widget_config_dict + and "params" not in key + and "inputParams" not in key + ): + widget_config_dict.update( + {key.replace("$.", ""): value} + ) + else: + widget_config_dict[key.replace("$.", "")] = value - # Then the widget config at the POST endpoint level takes priority. - if post_config := form_route.get("widget_config", {}): - for key, value in post_config.copy().items(): - if key.startswith("$."): - var_key[key] = value - - form_params = deep_merge_configs( - form_params, - { - k: v - for k, v in post_config.items() - if not k.startswith("$.") - }, - ) - - modified_query_schema.append(form_params) - - if var_key: - for key, value in var_key.items(): - if ( - key.replace("$.", "") in widget_config_dict - and "params" not in key - and "inputParams" not in key - ): - widget_config_dict.update( - {key.replace("$.", ""): value} - ) - else: - widget_config_dict[key.replace("$.", "")] = value - # For non-form endpoints, extend with the modified params directly - # else: - # modified_query_schema.extend(modified_post_params) provider_map = { "tmx": "TMX", "ecb": "ECB", @@ -528,14 +604,10 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- else "" ) widget_type = ( - ( - "markdown" - if isinstance(response_schema, dict) - and response_schema.get("type") == "string" - else "table" - ) - if route_method == "get" - else "omni" + "markdown" + if isinstance(response_schema, dict) + and response_schema.get("type") == "string" + else "table" ) widget_config = { "name": f"{name}" if name else route_api[route_method].get("summary"), @@ -545,11 +617,7 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- "searchCategory": category.replace("_", " ").title(), "widgetId": f"{widget_id}_{provider}_obb", "params": modified_query_schema, - "endpoint": ( - route.replace("/api", "api") - if "/api" in route - else route[1:] if route[0] == "/" else route - ), + "endpoint": route, "runButton": False, "gridData": {"w": 40, "h": 15}, "data": { @@ -609,9 +677,9 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- widget_config, widget_config_dict, ) - # TODO: uncomment when charting is implemented for advanced tables. - # if widget_config.get("type") == "table": - # widget_config["data"]["table"]["enableAdvanced"] = True + + if widget_config.get("type") == "table": + widget_config["data"]["table"]["enableAdvanced"] = True if widget_config.get("type") == "metric": widget_config["gridData"]["w"] = ( @@ -640,6 +708,16 @@ def build_json( # noqa: PLR0912 # pylint: disable=too-many-branches, too-many- else widget_config["gridData"].get("h") ) + if source := widget_config_dict.get("source", []): + widget_config["source"] = source + + if route_method == "post" and widget_config.get("type", "") not in [ + "ssrm_table", + "omni", + "multi_file_viewer", + ]: + widget_exclude_filter.append(widget_config["widgetId"]) + # Add the widget configuration to the widgets.json if widget_config["widgetId"] not in widget_exclude_filter: widgets_json[widget_config["widgetId"]] = widget_config diff --git a/openbb_platform/extensions/platform_api/tests/mock_openapi.json b/openbb_platform/extensions/platform_api/tests/mock_openapi.json index da7add7cfbd..56330dcee34 100644 --- a/openbb_platform/extensions/platform_api/tests/mock_openapi.json +++ b/openbb_platform/extensions/platform_api/tests/mock_openapi.json @@ -3538,10 +3538,15 @@ } } } - }, + } + }, + "/form_widget": { "get": { "summary": "All Forms", "operationId": "all_forms_form_submit_get", + "widget_config": { + "form_endpoint": "/form_submit" + }, "parameters": [ { "name": "some_param", diff --git a/openbb_platform/extensions/platform_api/tests/mock_widgets.json b/openbb_platform/extensions/platform_api/tests/mock_widgets.json index 607c763051f..42c276c6f31 100644 --- a/openbb_platform/extensions/platform_api/tests/mock_widgets.json +++ b/openbb_platform/extensions/platform_api/tests/mock_widgets.json @@ -125,7 +125,7 @@ "show": false } ], - "endpoint": "api/v1/economy/survey/sloos", + "endpoint": "/api/v1/economy/survey/sloos", "runButton": false, "gridData": { "w": 40, @@ -167,7 +167,8 @@ "headerTooltip": "Survey title.", "cellDataType": "text" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -292,7 +293,7 @@ "show": false } ], - "endpoint": "api/v1/economy/survey/university_of_michigan", + "endpoint": "/api/v1/economy/survey/university_of_michigan", "runButton": false, "gridData": { "w": 40, @@ -326,7 +327,8 @@ "cellDataType": "number", "renderFn": "greenRed" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -451,7 +453,7 @@ "show": false } ], - "endpoint": "api/v1/economy/survey/economic_conditions_chicago", + "endpoint": "/api/v1/economy/survey/economic_conditions_chicago", "runButton": false, "gridData": { "w": 40, @@ -533,7 +535,8 @@ "headerTooltip": "Non-Labor Costs Index.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -726,7 +729,7 @@ "show": false } ], - "endpoint": "api/v1/economy/survey/manufacturing_outlook_texas", + "endpoint": "/api/v1/economy/survey/manufacturing_outlook_texas", "runButton": false, "gridData": { "w": 40, @@ -783,7 +786,8 @@ "cellDataType": "number", "renderFn": "greenRed" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -898,7 +902,7 @@ "show": false } ], - "endpoint": "api/v1/economy/survey/nonfarm_payrolls", + "endpoint": "/api/v1/economy/survey/nonfarm_payrolls", "runButton": false, "gridData": { "w": 40, @@ -972,7 +976,8 @@ "headerTooltip": "The indentation level of the element.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -1275,7 +1280,7 @@ "show": false } ], - "endpoint": "api/v1/economy/cpi", + "endpoint": "/api/v1/economy/cpi", "runButton": false, "gridData": { "w": 40, @@ -1308,7 +1313,8 @@ "headerTooltip": "CPI index value or period change.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -1551,7 +1557,7 @@ "show": false } ], - "endpoint": "api/v1/economy/balance_of_payments", + "endpoint": "/api/v1/economy/balance_of_payments", "runButton": false, "gridData": { "w": 40, @@ -1714,7 +1720,8 @@ "headerTooltip": "Current Account Debits Total Secondary Income (USD)", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -1842,7 +1849,7 @@ "show": false } ], - "endpoint": "api/v1/economy/fred_search", + "endpoint": "/api/v1/economy/fred_search", "runButton": false, "gridData": { "w": 40, @@ -1995,7 +2002,8 @@ "headerTooltip": "The series group ID of the series. This value is used to query for regional data.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -2191,7 +2199,7 @@ "show": false } ], - "endpoint": "api/v1/economy/fred_series", + "endpoint": "/api/v1/economy/fred_series", "runButton": false, "gridData": { "w": 40, @@ -2210,7 +2218,8 @@ "headerTooltip": "The date of the data.", "cellDataType": "date" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -2415,7 +2424,7 @@ "show": false } ], - "endpoint": "api/v1/economy/fred_series", + "endpoint": "/api/v1/economy/fred_series", "runButton": false, "gridData": { "w": 40, @@ -2434,7 +2443,8 @@ "headerTooltip": "The date of the data.", "cellDataType": "date" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -2489,7 +2499,7 @@ "show": false } ], - "endpoint": "api/v1/economy/fred_release_table", + "endpoint": "/api/v1/economy/fred_release_table", "runButton": false, "gridData": { "w": 40, @@ -2577,7 +2587,8 @@ "headerTooltip": "The reported value of the series.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -2852,7 +2863,7 @@ "show": false } ], - "endpoint": "api/v1/economy/fred_regional", + "endpoint": "/api/v1/economy/fred_regional", "runButton": false, "gridData": { "w": 40, @@ -2900,7 +2911,8 @@ "headerTooltip": "The individual series ID for the region.", "cellDataType": "text" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3055,7 +3067,7 @@ "show": false } ], - "endpoint": "api/v1/economy/retail_prices", + "endpoint": "/api/v1/economy/retail_prices", "runButton": false, "gridData": { "w": 40, @@ -3104,7 +3116,8 @@ "headerTooltip": "Price, or change in price, per unit.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3183,7 +3196,7 @@ "show": false } ], - "endpoint": "api/v1/economy/pce", + "endpoint": "/api/v1/economy/pce", "runButton": false, "gridData": { "w": 40, @@ -3264,7 +3277,8 @@ "headerTooltip": "The line number of the series in the table.", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3304,7 +3318,7 @@ "show": false } ], - "endpoint": "api/v1/regulators/sec/cik_map", + "endpoint": "/api/v1/regulators/sec/cik_map", "runButton": false, "gridData": { "w": 40, @@ -3323,7 +3337,8 @@ "cellDataType": "text", "renderFn": null } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3363,7 +3378,7 @@ "show": false } ], - "endpoint": "api/v1/regulators/sec/institutions_search", + "endpoint": "/api/v1/regulators/sec/institutions_search", "runButton": false, "gridData": { "w": 40, @@ -3388,7 +3403,8 @@ "headerTooltip": "Central Index Key (CIK)", "cellDataType": "number" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3437,7 +3453,7 @@ "show": false } ], - "endpoint": "api/v1/regulators/sec/schema_files", + "endpoint": "/api/v1/regulators/sec/schema_files", "runButton": false, "gridData": { "w": 40, @@ -3455,7 +3471,8 @@ "headerTooltip": "Dictionary of URLs to SEC Schema Files", "cellDataType": "text" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3495,7 +3512,7 @@ "show": false } ], - "endpoint": "api/v1/regulators/sec/symbol_map", + "endpoint": "/api/v1/regulators/sec/symbol_map", "runButton": false, "gridData": { "w": 40, @@ -3515,7 +3532,8 @@ "cellDataType": "text", "renderFn": null } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3537,7 +3555,7 @@ "show": false } ], - "endpoint": "api/v1/regulators/sec/rss_litigation", + "endpoint": "/api/v1/regulators/sec/rss_litigation", "runButton": false, "gridData": { "w": 40, @@ -3584,7 +3602,8 @@ "headerTooltip": "URL to the release.", "cellDataType": "text" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3624,7 +3643,7 @@ "show": false } ], - "endpoint": "api/v1/regulators/sec/sic_search", + "endpoint": "/api/v1/regulators/sec/sic_search", "runButton": false, "gridData": { "w": 40, @@ -3656,7 +3675,8 @@ "headerTooltip": "Reporting office within the Corporate Finance Office", "cellDataType": "text" } - ] + ], + "enableAdvanced": true } }, "source": [ @@ -3664,13 +3684,13 @@ ], "subCategory": "SEC" }, - "form_submit_custom_obb": { - "name": "Form Submit", + "form_widget_custom_obb": { + "name": "Form Widget", "description": "", - "category": "Form Submit", + "category": "Form Widget", "type": "table", - "searchCategory": "Form Submit", - "widgetId": "form_submit_custom_obb", + "searchCategory": "Form Widget", + "widgetId": "form_widget_custom_obb", "params": [ { "label": "Some Param", @@ -3683,10 +3703,10 @@ }, { "type": "form", - "paramName": "", + "paramName": "form", "label": "Submit Form", "description": "Submit a form via POST request.", - "endpoint": "form_submit", + "endpoint": "/form_submit", "inputParams": [ { "label": "Date", @@ -3762,7 +3782,7 @@ ] } ], - "endpoint": "form_submit", + "endpoint": "/form_widget", "runButton": false, "gridData": { "w": 40, @@ -3771,11 +3791,13 @@ "data": { "dataKey": "", "table": { - "showAll": true + "showAll": true, + "enableAdvanced": true } }, "source": [ "Custom" - ] + ], + "form_endpoint": "/form_submit" } } \ No newline at end of file diff --git a/openbb_platform/extensions/platform_api/tests/test_openapi_utils.py b/openbb_platform/extensions/platform_api/tests/test_openapi_utils.py index de5a63095b7..2cf7de7c478 100644 --- a/openbb_platform/extensions/platform_api/tests/test_openapi_utils.py +++ b/openbb_platform/extensions/platform_api/tests/test_openapi_utils.py @@ -1,3 +1,8 @@ +"""Test OpenAPI Utils.""" + +# pylint: disable=redefined-outer-name,line-too-long +# flake8: noqa: E501 + import json from pathlib import Path @@ -9,8 +14,6 @@ from openbb_platform_api.utils.openapi import ( post_query_schema_for_widget, ) -# pylint: disable=redefined-outer-name - # Load the mock OpenAPI JSON @pytest.fixture(scope="module") @@ -100,7 +103,6 @@ def mock_openapi_json(): "x-widget_config": {}, "available_providers": ["fred"], "show": True, - "x-widget_config": {}, }, { "parameter_name": "start_date", @@ -114,7 +116,6 @@ def mock_openapi_json(): "x-widget_config": {}, "available_providers": ["fred"], "show": True, - "x-widget_config": {}, }, { "parameter_name": "end_date", @@ -128,7 +129,6 @@ def mock_openapi_json(): "x-widget_config": {}, "available_providers": ["fred"], "show": True, - "x-widget_config": {}, }, ], False, @@ -170,7 +170,6 @@ def mock_openapi_json(): "options": {"fred": []}, "x-widget_config": {}, "show": True, - "x-widget_config": {}, }, { "parameter_name": "end_date", @@ -183,7 +182,6 @@ def mock_openapi_json(): "options": {"fred": []}, "x-widget_config": {}, "show": True, - "x-widget_config": {}, }, { "parameter_name": "limit", @@ -196,7 +194,6 @@ def mock_openapi_json(): "options": {"fred": []}, "x-widget_config": {}, "show": True, - "x-widget_config": {}, }, { "parameter_name": "frequency", @@ -227,12 +224,13 @@ def mock_openapi_json(): "x-widget_config": {}, "available_providers": ["fred"], "show": True, - "x-widget_config": {}, }, { "parameter_name": "aggregation_method", "label": "Aggregation Method", - "description": "A key that indicates the aggregation method used for frequency aggregation.\n This parameter has no affect if the frequency parameter is not set.\n \n avg = Average\n \n sum = Sum\n \n eop = End of Period", + "description": "A key that indicates the aggregation method used for frequency aggregation.\n" + + " This parameter has no affect if the frequency parameter is not set.\n" + + " \n avg = Average\n \n sum = Sum\n \n eop = End of Period", "optional": True, "type": "text", "value": "eop", @@ -247,7 +245,6 @@ def mock_openapi_json(): "x-widget_config": {}, "available_providers": ["fred"], "show": True, - "x-widget_config": {}, }, { "parameter_name": "transform", @@ -272,7 +269,6 @@ def mock_openapi_json(): "x-widget_config": {}, "available_providers": ["fred"], "show": True, - "x-widget_config": {}, }, ], True, @@ -302,7 +298,6 @@ def mock_openapi_json(): "options": {"sec": []}, "x-widget_config": {}, "show": True, - "x-widget_config": {}, }, { "parameter_name": "use_cache", @@ -327,7 +322,6 @@ def mock_openapi_json(): "options": {"sec": []}, "x-widget_config": {}, "available_providers": ["sec"], - "x-widget_config": {}, "show": True, }, ], diff --git a/openbb_platform/extensions/platform_api/tests/test_widgets_utils.py b/openbb_platform/extensions/platform_api/tests/test_widgets_utils.py index ad9a46e59e7..f37671065a1 100644 --- a/openbb_platform/extensions/platform_api/tests/test_widgets_utils.py +++ b/openbb_platform/extensions/platform_api/tests/test_widgets_utils.py @@ -498,9 +498,9 @@ def test_modify_query_schema(query_schema, provider_value, expected_result): def test_build_json(mock_openapi_json, mock_widgets_json): result = build_json(openapi=mock_openapi_json, widget_exclude_filter=[]) - assert ( - len(result) == len(mock_openapi_json["paths"]) + 1 - ) # +1 for the duplicate path with a chart. + assert len(result) == len( + mock_openapi_json["paths"] + ) # +1 for the duplicate path with a chart. -1 for the form widget over 2 paths. assert result == mock_widgets_json diff --git a/openbb_platform/providers/nasdaq/tests/record/expected_widgets.json b/openbb_platform/providers/nasdaq/tests/record/expected_widgets.json index 35a23234601..9e5441e7f85 100644 --- a/openbb_platform/providers/nasdaq/tests/record/expected_widgets.json +++ b/openbb_platform/providers/nasdaq/tests/record/expected_widgets.json @@ -210,7 +210,7 @@ ] } ], - "endpoint": "open_document", + "endpoint": "/open_document", "runButton": false, "gridData": { "w": 40, @@ -264,7 +264,7 @@ } } ], - "endpoint": "earnings_calendar", + "endpoint": "/earnings_calendar", "runButton": false, "gridData": { "w": 40, @@ -414,7 +414,7 @@ } } ], - "endpoint": "dividend_calendar", + "endpoint": "/dividend_calendar", "runButton": false, "gridData": { "w": 40, @@ -538,7 +538,7 @@ "paramName": "end_date" } ], - "endpoint": "historical_dividends", + "endpoint": "/historical_dividends", "runButton": false, "gridData": { "w": 40, @@ -548,6 +548,7 @@ "dataKey": "", "table": { "showAll": true, + "enableAdvanced": true, "columnsDefs": [ { "field": "ex_dividend_date", @@ -679,7 +680,7 @@ "paramName": "is_spo" } ], - "endpoint": "ipo_calendar", + "endpoint": "/ipo_calendar", "runButton": false, "gridData": { "w": 40, @@ -888,7 +889,7 @@ ] } ], - "endpoint": "economic_calendar", + "endpoint": "/economic_calendar", "runButton": false, "gridData": { "w": 40,