Warning

This document is for an in-development version of Galaxy. You can alternatively view this page in the latest release if it exists or view the top of the latest release's documentation.

Source code for galaxy.webapps.galaxy.fast_app

import logging
from contextlib import asynccontextmanager
from typing import (
    Any,
    TYPE_CHECKING,
)
from urllib.parse import urljoin

from a2wsgi import WSGIMiddleware
from fastapi import (
    FastAPI,
    Request,
)
from fastapi.openapi.constants import REF_TEMPLATE
from slowapi import (
    _rate_limit_exceeded_handler,
    Limiter,
)
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from starlette.datastructures import MutableHeaders
from starlette.middleware.cors import CORSMiddleware
from tuspyserver import create_tus_router

from galaxy.schema.generics import ref_to_name
from galaxy.version import VERSION
from galaxy.webapps.base.api import (
    add_exception_handler,
    add_raw_context_middlewares,
    add_request_id_middleware,
    build_route_name_index,
    GalaxyFileResponse,
    include_all_package_routers,
)
from galaxy.webapps.base.webapp import (
    _is_embed_request,
    config_allows_origin,
)
from galaxy.webapps.openapi._compat.v2 import GenerateJsonSchema
from galaxy.webapps.openapi.utils import get_openapi

if TYPE_CHECKING:
    from pydantic.json_schema import (
        CoreModeRef,
        DefsRef,
    )

# https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-tags
api_tags_metadata = [
    {
        "name": "configuration",
        "description": "Configuration-related endpoints.",
    },
    {"name": "datasets", "description": "Operations on datasets."},
    {"name": "dataset collections"},
    {
        "name": "datatypes",
        "description": "Operations with supported data types.",
    },
    {
        "name": "datatypes",
        "description": "Operations on dataset collections.",
    },
    {
        "name": "genomes",
        "description": "Operations with genome data.",
    },
    {
        "name": "group_roles",
        "description": "Operations with group roles.",
    },
    {
        "name": "groups",
        "description": "Operations with groups.",
    },
    {
        "name": "group_users",
        "description": "Operations with group users.",
    },
    {"name": "histories"},
    {"name": "libraries"},
    {"name": "data libraries folders"},
    {"name": "job_lock"},
    {"name": "default"},
    {"name": "users"},
    {"name": "jobs"},
    {"name": "roles"},
    {"name": "quotas"},
    {"name": "visualizations"},
    {"name": "pages"},
    {
        "name": "licenses",
        "description": "Operations with [SPDX licenses](https://spdx.org/licenses/).",
    },
    {
        "name": "tags",
        "description": "Operations with tags.",
    },
    {
        "name": "tool data tables",
        "description": "Operations with tool [Data Tables](https://galaxyproject.org/admin/tools/data-tables/).",
    },
    {
        "name": "tours",
        "description": "Operations with interactive tours.",
    },
    {
        "name": "remote files",
        "description": "Operations with remote dataset sources.",
    },
    {"name": "undocumented", "description": "API routes that have not yet been ported to FastAPI."},
    {
        "name": "ai",
        "description": "**BETA**: AI agent operations. This API is experimental and may change without notice.",
    },
    {
        "name": "chat",
        "description": "**BETA**: Chat interface for AI agents. This API is experimental and may change without notice.",
    },
]


class GalaxyCORSMiddleware(CORSMiddleware):
    def __init__(self, *args, **kwds):
        self.config = kwds.pop("config")
        super().__init__(*args, **kwds)

    def is_allowed_origin(self, origin: str) -> bool:
        return config_allows_origin(origin, self.config)


class XFrameOptionsMiddleware:
    def __init__(self, app, x_frame_options: str):
        self.app = app
        self.x_frame_options = x_frame_options

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        request = Request(scope)

        async def send_with_header(message):
            if message["type"] == "http.response.start":
                if not _is_embed_request(request.url.path, str(request.url.query)):
                    headers = MutableHeaders(scope=message)
                    headers.append("X-Frame-Options", self.x_frame_options)
            await send(message)

        await self.app(scope, receive, send_with_header)


[docs] def add_galaxy_middleware(app: FastAPI, gx_app): if x_frame_options := gx_app.config.x_frame_options: app.add_middleware(XFrameOptionsMiddleware, x_frame_options=x_frame_options) GalaxyFileResponse.nginx_x_accel_redirect_base = gx_app.config.nginx_x_accel_redirect_base GalaxyFileResponse.apache_xsendfile = gx_app.config.apache_xsendfile if gx_app.config.get("allowed_origin_hostnames", None): app.add_middleware( GalaxyCORSMiddleware, config=gx_app.config, allow_headers=["*"], allow_methods=["*"], max_age=600, ) from galaxy.web.framework.middleware.aiocop_integration import aiocop_enabled if aiocop_enabled(): from galaxy.web.framework.middleware.aiocop_integration import AiocopMiddleware app.add_middleware(AiocopMiddleware)
def _build_merged_openapi(app, gx_app): openapi_schema = get_openapi( title="Galaxy API", version=VERSION, routes=app.routes, tags=api_tags_metadata, ) legacy_openapi = gx_app.api_spec.to_dict() legacy_openapi["paths"].update(openapi_schema["paths"]) openapi_schema["paths"] = legacy_openapi["paths"] return openapi_schema def include_legacy_openapi(app, gx_app): """Merge the legacy paste API spec into the FastAPI-generated schema. Built eagerly so production workers can serve ``/openapi.json`` immediately on first request without paying a multi-second merge latency on that request. """ if app.openapi_schema: return app.openapi_schema app.openapi_schema = _build_merged_openapi(app, gx_app) return app.openapi_schema def get_fastapi_instance(root_path="", lifespan=None) -> FastAPI: return FastAPI( title="Galaxy API", docs_url="/api/docs", redoc_url="/api/redoc", openapi_tags=api_tags_metadata, license_info={"name": "MIT", "url": "https://github.com/galaxyproject/galaxy/blob/dev/LICENSE.txt"}, root_path=root_path, lifespan=lifespan, ) class CustomJsonSchema(GenerateJsonSchema): def get_defs_ref(self, core_mode_ref: "CoreModeRef") -> "DefsRef": full_def = super().get_defs_ref(core_mode_ref) choices = self._prioritized_defsref_choices[full_def] ref, mode = core_mode_ref if ref in ref_to_name: for i, choice in enumerate(choices): choices[i] = choice.replace(choices[0], ref_to_name[ref]) # type: ignore[call-overload] return full_def def get_openapi_schema() -> dict[str, Any]: """ Dumps openAPI schema without starting a full app and webserver. """ app = get_fastapi_instance() include_all_package_routers(app, "galaxy.webapps.galaxy.api") return get_openapi( title=app.title, version=app.version, openapi_version="3.1.0", description=app.description, routes=app.routes, license_info=app.license_info, schema_generator=CustomJsonSchema(ref_template=REF_TEMPLATE), ) def include_tus(app: FastAPI, gx_app): config = gx_app.config root_path = "" if config.galaxy_url_prefix == "/" else config.galaxy_url_prefix upload_tus_router = create_tus_router( prefix=urljoin(root_path, "api/upload/resumable_upload"), files_dir=config.tus_upload_store or config.new_file_path, max_size=config.maximum_upload_file_size, ) job_files_tus_router = create_tus_router( prefix=urljoin(root_path, "api/job_files/resumable_upload"), files_dir=config.tus_upload_store_job_files or config.tus_upload_store or config.new_file_path, max_size=config.maximum_upload_file_size, ) app.include_router(upload_tus_router) app.include_router(job_files_tus_router) log = logging.getLogger(__name__) def get_mcp_lifespan(gx_app): """Get MCP lifespan if enabled, or (None, None).""" if not gx_app.config.enable_mcp_server: return None, None try: from galaxy.webapps.galaxy.api.mcp import get_mcp_app mcp_app = get_mcp_app(gx_app) return mcp_app, mcp_app.lifespan except ImportError: log.info("MCP server dependencies not installed (fastmcp), skipping") return None, None except Exception: log.exception("Failed to initialize MCP server") return None, None def include_mcp(app: FastAPI, gx_app, mcp_app): """Mount the MCP server if it was initialized.""" if mcp_app is None: return try: mcp_path = gx_app.config.mcp_server_path # Requests served by the mounted sub-app see request.app == mcp_app, so # share the parent's route name index for UrlBuilder._url_path_for. mcp_app.state.route_name_index = app.state.route_name_index app.mount(mcp_path, mcp_app) log.info(f"MCP server (Streamable HTTP) mounted at {mcp_path}") except Exception as e: log.error(f"Failed to mount MCP server: {e}")
[docs] def initialize_fast_app(gx_wsgi_webapp, gx_app): """Build the FastAPI app that fronts the Galaxy web server.""" root_path = "" if gx_app.config.galaxy_url_prefix == "/" else gx_app.config.galaxy_url_prefix mcp_app, mcp_lifespan = get_mcp_lifespan(gx_app) if mcp_lifespan: @asynccontextmanager async def combined_lifespan(app: FastAPI): async with mcp_lifespan(app): yield app = get_fastapi_instance(root_path=root_path, lifespan=combined_lifespan) else: app = get_fastapi_instance(root_path=root_path) add_exception_handler(app) add_galaxy_middleware(app, gx_app) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type] if gx_app.config.use_access_logging_middleware: add_raw_context_middlewares(app) else: add_request_id_middleware(app) include_all_package_routers(app, "galaxy.webapps.galaxy.api") include_legacy_openapi(app, gx_app) wsgi_handler = WSGIMiddleware(gx_wsgi_webapp) gx_app.haltables.append(("WSGI Middleware threadpool", wsgi_handler.executor.shutdown)) include_tus(app, gx_app) app.state.route_name_index = build_route_name_index(app) include_mcp(app, gx_app, mcp_app) app.mount("/", wsgi_handler) # type: ignore[arg-type] if gx_app.config.galaxy_url_prefix != "/": parent_app = FastAPI() parent_app.mount(gx_app.config.galaxy_url_prefix, app=app) return parent_app return app
def galaxy_rate_limit_key(request: Request) -> str: api_key = request.headers.get("x-api-key") or request.query_params.get("key") if api_key: return f"api_key:{api_key}" session_key = request.cookies.get("galaxysession") if session_key: return f"session:{session_key}" return get_remote_address(request) limiter = Limiter(key_func=galaxy_rate_limit_key) __all__ = ( "add_galaxy_middleware", "initialize_fast_app", )