Panel app in FastAPI Router

Hi all,

Was trying Running Panel apps in FASTAPI and have the example up and running.

But in a more real world use case, I would need to run it within routers - even if just for source code structuring.

Taking Marc’s example and extending it as below:

import panel as pn

from fastapi import APIRouter, FastAPI
from panel.io.fastapi import add_application

app = FastAPI()

router = APIRouter()
    

@app.get("/")
async def read_root():
    return {"Hello": "World"}


@add_application('/panel_1', app=app, title='My Panel App')
def create_panel_app():    
    """!
    Create a dummy panel application over a FastAPI interface
    
    See here: https://github.com/holoviz/panel/issues/7338
    For making API args accessible within the method
    
    """
    slider_1 = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3)
    
    if pn.state.location:
        # To populate slider_value in the API call, use a string like
        # /panel?slider_1_value=6&slider_A_value=3
        # If the GET params are not entered in the call, then the previous 
        # entry is used
        pn.state.location.sync(slider_1, {"value": "slider_1_value"})
        
    return pn.Row(slider_1.rx() * '⭐')


@add_application('/panel_2', app=router, title='My Panel App')
def create_panel_app_router():
    """!
    Dummy panel within a router
    
    """
    slider_1 = pn.widgets.IntSlider(name='Slider', start=0, end=10, value=3)
    
    if pn.state.location:
        print(pn.state.location.sync(slider_1, {"value": "slider_1_value"}))
    
    return pn.Row(slider_1.rx() * '⭐')

app.include_router(router,
                   prefix="/test_router",
                   )

But on initial running, there were complaints within the bokeh_fastapi.application.BokehFastAPI class because a Router instance has been supplied rather than a FastAPI app instance.

I then tried to dummy this module by modifying as code snip below (only top to tail of section that changed shown). Basically I mapped the Router onto a dummy representation of a FastAPI app.

This worked… but only if prefix=“/test_router” does not exist when specifying the app.include_router() line in main.py

While this in itself is useful - it does allow source code structuring - it would be neater if it did align with the router paths, so onward the investigation went.

When specifying the prefix, while the endpoint is visible on the openAPI /docs page, attempting to access the endpoint will return multiple failures to access .js files in paths:
/test_router/static/extensions/panel/… …/es-module-shims.min.js
/test_router/static/js/bokeh.min.js
/test_router/static/js/bokeh-gl.min.js
/test_router/static/extensions/panel/panel.min.js
/test_router/static/js/bokeh-widgets.min.js
/test_router/static/js/bokeh-tables.min.js

Circled around it a few times now from different directions trying to specify StaticFiles and mount the app - but to be honest not really knowing what I’m doing its akin to pinning the tail on a donkey! :smile:

So putting this infront of the experts who do actually understand the library and see what you all think.

bokeh_fastapi\application.py

from fastapi import ( 
    applications as fastapi_applications, 
    FastAPI,
    routing as fastapi_routing,
    )

# -----------------------------------------------------------------------------
class RouterApp:
    """
    A dummy representation of a Router instance in the same structure as a 
    FastAPI instance that enables use of BokehFastAPI without cascading changes
    
    
    router (FastAPI Router) :
        FastAPI router that we will serve the application through
            
    """

    # -------------------------------------------------------------------------    
    def __init__(self, *, 
                 router: fastapi_routing.APIRouter,
                 prefix: str,
                 ):
        """
        Create this dummy FastAPI instance
            
        """
        self.router = router
        self.add_api_route = router.add_api_route
        self.add_websocket_route = router.add_websocket_route
        self.root_path = prefix
        self.get = router.get


# -----------------------------------------------------------------------------
class BokehFastAPI:
    """
    applications (dict[str,Application] or Application) :
        A map from paths to ``Application`` instances.

        If the value is a single Application, then the following mapping
        is generated:

        .. code-block:: python

            applications = {{ '/' : applications }}

        When a connection comes in to a given path, the associate
        Application is used to generate a new document for the session.

    app (FastAPI, optional) :
        FastAPI app to serve the ``applications`` from.

    prefix (str, optional) :
        A URL prefix to use for all Bokeh server paths. (default: None)

    websocket_origins (Sequence[str], optional) :
        A set of websocket origins permitted to connect to this server.

    secret_key (str, optional) :
        A secret key for signing session IDs.

        Defaults to the current value of the environment variable
        ``BOKEH_SECRET_KEY``

    sign_sessions (bool, optional) :
        Whether to cryptographically sign session IDs

        Defaults to the current value of the environment variable
        ``BOKEH_SIGN_SESSIONS``. If ``True``, then ``secret_key`` must
        also be provided (either via environment setting or passed as
        a parameter value)

    keep_alive_milliseconds (int, optional) :
        Number of milliseconds between keep-alive pings
        (default: {DEFAULT_KEEP_ALIVE_MS})

        Pings normally required to keep the websocket open. Set to 0 to
        disable pings.

    check_unused_sessions_milliseconds (int, optional) :
        Number of milliseconds between checking for unused sessions
        (default: {DEFAULT_CHECK_UNUSED_MS})

    unused_session_lifetime_milliseconds (int, optional) :
        Number of milliseconds for unused session lifetime
        (default: {DEFAULT_UNUSED_LIFETIME_MS})

    include_headers (list, optional) :
            List of request headers to include in session context
            (by default all headers are included)

    exclude_headers (list, optional) :
        List of request headers to exclude in session context
        (by default all headers are included)

    include_cookies (list, optional) :
        List of cookies to include in session context
        (by default all cookies are included)

    exclude_cookies (list, optional) :
        List of cookies to exclude in session context
        (by default all cookies are included)
    """

    def __init__(
        self,
        applications: Mapping[str, Application | ModifyDoc] | Application | ModifyDoc,
        app: FastAPI | None = None,
        prefix: str | None = None,
        websocket_origins: Sequence[str] | None = None,
        secret_key: bytes | None = settings.secret_key_bytes(),
        sign_sessions: bool = settings.sign_sessions(),
        keep_alive_milliseconds: int = DEFAULT_KEEP_ALIVE_MS,
        check_unused_sessions_milliseconds: int = DEFAULT_CHECK_UNUSED_MS,
        unused_session_lifetime_milliseconds: int = DEFAULT_UNUSED_LIFETIME_MS,
        include_headers: list[str] | None = None,
        include_cookies: list[str] | None = None,
        exclude_headers: list[str] | None = None,
        exclude_cookies: list[str] | None = None,
    ):
        if callable(applications):
            applications = Application(FunctionHandler(applications))
        if isinstance(applications, Application):
            applications = {"/": applications}
        else:
            applications = dict(applications)

        for url, application in applications.items():
            if callable(application):
                applications[url] = application = Application(
                    FunctionHandler(application)
                )
            if all(
                not isinstance(handler, DocumentLifecycleHandler)
                for handler in application._handlers
            ):
                application.add(DocumentLifecycleHandler())
        applications = cast(dict[str, Application], applications)

        # Wrap applications in ApplicationContext
        self._applications = {}
        for url, application in applications.items():
            self._applications[url] = ApplicationContext(application, url=url)

        if app is None:
            app = FastAPI()
        
        if isinstance(app, fastapi_applications.FastAPI):            
            self.app = app
            
        elif isinstance(app, fastapi_routing.APIRouter):
            self.app = RouterApp(
                router=app,
                prefix=prefix,
                )

Thanks for sharing! Perhaps you can submit an issue, and even a PR to Panel on GitHub? Much appreciated!

Thanks for quick feedback - will raise as enhancement. :+1:

Would hesitate to make a PR while it still doesn’t work with a router prefix.
Others may end up with a more elegant solution that fixes that too.

edit: Issue is here

1 Like