diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aad387787..a6a2224907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool - [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes. - [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph. +- [#3347](https://github.com/plotly/dash/pull/3347) Added 'api_endpoint' to `callback` to expose api endpoints at the provided path for use to be executed directly without dash. ## Fixed - [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316) diff --git a/dash/_callback.py b/dash/_callback.py index dd757028c2..aacb8dbdde 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -62,6 +62,7 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d GLOBAL_CALLBACK_LIST = [] GLOBAL_CALLBACK_MAP = {} GLOBAL_INLINE_SCRIPTS = [] +GLOBAL_API_PATHS = {} # pylint: disable=too-many-locals,too-many-arguments @@ -77,6 +78,7 @@ def callback( cache_args_to_ignore: Optional[list] = None, cache_ignore_triggered=True, on_error: Optional[Callable[[Exception], Any]] = None, + api_endpoint: Optional[str] = None, optional: Optional[bool] = False, hidden: Optional[bool] = False, **_kwargs, @@ -165,6 +167,14 @@ def callback( Mark all dependencies as not required on the initial layout checks. :param hidden: Hide the callback from the devtools callbacks tab. + :param api_endpoint: + If provided, the callback will be available at the given API endpoint. + This allows you to call the callback directly through HTTP requests + instead of through the Dash front-end. The endpoint should be a string + that starts with a forward slash (e.g. `/my_callback`). + The endpoint is relative to the Dash app's base URL. + Note that the endpoint will not appear in the list of registered + callbacks in the Dash devtools. """ background_spec = None @@ -219,6 +229,7 @@ def callback( manager=manager, running=running, on_error=on_error, + api_endpoint=api_endpoint, optional=optional, hidden=hidden, ) @@ -587,7 +598,11 @@ def _prepare_response( # pylint: disable=too-many-branches,too-many-statements def register_callback( - callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs + callback_list, + callback_map, + config_prevent_initial_callbacks, + *_args, + **_kwargs, ): ( output, @@ -642,6 +657,10 @@ def register_callback( # pylint: disable=too-many-locals def wrap_func(func): + if _kwargs.get("api_endpoint"): + api_endpoint = _kwargs.get("api_endpoint") + GLOBAL_API_PATHS[api_endpoint] = func + if background is None: background_key = None else: diff --git a/dash/dash.py b/dash/dash.py index c4181ef80e..8430259c27 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -569,6 +569,7 @@ def __init__( # pylint: disable=too-many-statements self.callback_map = {} # same deps as a list to catch duplicate outputs, and to send to the front end self._callback_list = [] + self.callback_api_paths = {} # list of inline scripts self._inline_scripts = [] @@ -783,6 +784,54 @@ def _setup_routes(self): # catch-all for front-end routes, used by dcc.Location self._add_url("", self.index) + def setup_apis(self): + """ + Register API endpoints for all callbacks defined using `dash.callback`. + + This method must be called after all callbacks are registered and before the app is served. + It ensures that all callback API routes are available for the Dash app to function correctly. + + Typical usage: + app = Dash(__name__) + # Register callbacks here + app.setup_apis() + app.run() + + If not called, callback endpoints will not be available and the app will not function as expected. + """ + for k in list(_callback.GLOBAL_API_PATHS): + if k in self.callback_api_paths: + raise DuplicateCallback( + f"The callback `{k}` provided with `dash.callback` was already " + "assigned with `app.callback`." + ) + self.callback_api_paths[k] = _callback.GLOBAL_API_PATHS.pop(k) + + def make_parse_body(func): + def _parse_body(): + if flask.request.is_json: + data = flask.request.get_json() + return flask.jsonify(func(**data)) + return flask.jsonify({}) + + return _parse_body + + def make_parse_body_async(func): + async def _parse_body_async(): + if flask.request.is_json: + data = flask.request.get_json() + result = await func(**data) + return flask.jsonify(result) + return flask.jsonify({}) + + return _parse_body_async + + for path, func in self.callback_api_paths.items(): + if asyncio.iscoroutinefunction(func): + self._add_url(path, make_parse_body_async(func), ["POST"]) + else: + self._add_url(path, make_parse_body(func), ["POST"]) + def _setup_plotlyjs(self): # pylint: disable=import-outside-toplevel from plotly.offline import get_plotlyjs_version @@ -1364,6 +1413,7 @@ def callback(self, *_args, **_kwargs) -> Callable[..., Any]: config_prevent_initial_callbacks=self.config.prevent_initial_callbacks, callback_list=self._callback_list, callback_map=self.callback_map, + callback_api_paths=self.callback_api_paths, **_kwargs, ) @@ -1516,6 +1566,7 @@ def dispatch(self): def _setup_server(self): if self._got_first_request["setup_server"]: return + self._got_first_request["setup_server"] = True # Apply _force_eager_loading overrides from modules diff --git a/tests/integration/callbacks/test_api_callback.py b/tests/integration/callbacks/test_api_callback.py new file mode 100644 index 0000000000..be06936c15 --- /dev/null +++ b/tests/integration/callbacks/test_api_callback.py @@ -0,0 +1,59 @@ +from dash import ( + Dash, + Input, + Output, + html, + ctx, +) +import requests +import json +from flask import jsonify + +test_string = ( + '{"step_0": "Data fetched - 1", "step_1": "Data fetched - 1", "step_2": "Data fetched - 1", ' + '"step_3": "Data fetched - 1", "step_4": "Data fetched - 1"}' +) + + +def test_apib001_api_callback(dash_duo): + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Slow Callback", id="slow-btn"), + html.Div(id="slow-output"), + ] + ) + + def get_data(n_clicks): + # Simulate an async data fetch + return f"Data fetched - {n_clicks}" + + @app.callback( + Output("slow-output", "children"), + Input("slow-btn", "n_clicks"), + prevent_initial_call=True, + api_endpoint="/api/slow_callback", # Example API path for the slow callback + ) + def slow_callback(n_clicks): + data = {} + for i in range(5): + data[f"step_{i}"] = get_data(n_clicks) + ret = f"{json.dumps(data)}" + if ctx: + return ret + return jsonify(ret) + + app.setup_apis() + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#slow-btn").click() + dash_duo.wait_for_text_to_equal("#slow-output", test_string) + r = requests.post( + dash_duo.server_url + "/api/slow_callback", + json={"n_clicks": 1}, + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 200 + assert r.json() == test_string