Files
galaxy/lib/tool_shed/webapp/api2/__init__.py
T
Nicola Soranzo 7f89e0eab9 Code cleanups from pyupgrade
Found by running:

```
make pyupgrade
ruff check --fix .
ruff check --preview --unsafe-fixes --select=F401 --fix .
make format
make update-client-api-schema
```

The bulk of the changes are replacements of typing deprecated aliases
such as `Dict` and `List` with the standard library classes. These
aliases became redundant in Python 3.9 when the corresponding
pre-existing classes were enhanced to support `[]`, see
https://docs.python.org/3/library/typing.html#deprecated-aliases

Also:
- Update the `pyupgrade` Makefile target to use `--py38-plus` for the
  packages needed by Pulsar, including the new galaxy-tool-util-models
  package.
2025-07-31 11:17:22 +01:00

383 lines
13 KiB
Python

import logging
from collections.abc import AsyncGenerator
from json import JSONDecodeError
from typing import (
cast,
Optional,
TypeVar,
)
from fastapi import (
Depends,
HTTPException,
Path,
Query,
Request,
Response,
Security,
)
from fastapi.security import (
APIKeyCookie,
APIKeyHeader,
APIKeyQuery,
)
from pydantic import BaseModel
from starlette_context import context as request_context
from galaxy.exceptions import AdminRequiredException
from galaxy.managers.session import GalaxySessionManager
from galaxy.managers.users import UserManager
from galaxy.security.idencoding import IdEncodingHelper
from galaxy.util import unicodify
from galaxy.web.framework.decorators import require_admin_message
from galaxy.webapps.base.webapp import create_new_session
from galaxy.webapps.galaxy.api import (
depends as framework_depends,
FrameworkRouter,
GalaxyASGIRequest,
GalaxyASGIResponse,
T,
UrlBuilder,
)
from tool_shed.context import (
SessionRequestContext,
SessionRequestContextImpl,
)
from tool_shed.structured_app import ToolShedApp
from tool_shed.webapp import app as tool_shed_app_mod
from tool_shed.webapp.model import (
GalaxySession,
User,
)
from tool_shed_client.schema import IndexSortByType
log = logging.getLogger(__name__)
def get_app() -> ToolShedApp:
if tool_shed_app_mod.app is None:
raise Exception("Failed to initialize the tool shed app correctly for FastAPI")
return tool_shed_app_mod.app
async def get_app_with_request_session() -> AsyncGenerator[ToolShedApp, None]:
app = get_app()
request_id = request_context.data["X-Request-ID"]
app.model.set_request_id(request_id)
try:
yield app
finally:
app.model.unset_request_id(request_id)
DependsOnApp = cast(ToolShedApp, Depends(get_app_with_request_session))
AUTH_COOKIE_NAME = "galaxycommunitysession"
api_key_query = APIKeyQuery(name="key", auto_error=False)
api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)
api_key_cookie = APIKeyCookie(name=AUTH_COOKIE_NAME, auto_error=False)
def depends(dep_type: type[T]) -> T:
return framework_depends(dep_type, app=get_app_with_request_session)
def get_api_user(
user_manager: UserManager = depends(UserManager),
key: str = Security(api_key_query),
x_api_key: str = Security(api_key_header),
) -> Optional[User]:
api_key = key or x_api_key
if not api_key:
return None
user = user_manager.by_api_key(api_key=api_key)
return user
def get_session_manager(app: ToolShedApp = DependsOnApp) -> GalaxySessionManager:
# TODO: find out how to adapt dependency for Galaxy/Report/TS
return GalaxySessionManager(app.model)
def get_session(
session_manager=cast(GalaxySessionManager, Depends(get_session_manager)),
security: IdEncodingHelper = depends(IdEncodingHelper),
galaxysession: str = Security(api_key_cookie),
) -> Optional[GalaxySession]:
if galaxysession:
session_key = security.decode_guid(galaxysession)
if session_key:
return session_manager.get_session_from_session_key(session_key)
# TODO: What should we do if there is no session? Since this is the API, maybe nothing is the right choice?
return None
def get_user(
galaxy_session=cast(Optional[GalaxySession], Depends(get_session)),
api_user=cast(Optional[User], Depends(get_api_user)),
) -> Optional[User]:
if galaxy_session:
return galaxy_session.user
return api_user
def get_trans(
request: Request,
response: Response,
app: ToolShedApp = DependsOnApp,
user=cast(Optional[User], Depends(get_user)),
galaxy_session=cast(Optional[GalaxySession], Depends(get_session)),
) -> SessionRequestContext:
url_builder = UrlBuilder(request)
galaxy_request = GalaxyASGIRequest(request)
galaxy_response = GalaxyASGIResponse(response)
return SessionRequestContextImpl(
app,
galaxy_request,
galaxy_response,
user=user,
galaxy_session=galaxy_session,
url_builder=url_builder,
)
DependsOnTrans: SessionRequestContext = cast(SessionRequestContext, Depends(get_trans))
def get_admin_user(trans: SessionRequestContext = DependsOnTrans):
if not trans.user_is_admin:
raise AdminRequiredException(require_admin_message(trans.app.config, trans.user))
return trans.user
AdminUserRequired = Depends(get_admin_user)
class Router(FrameworkRouter):
admin_user_dependency = AdminUserRequired
B = TypeVar("B", bound=BaseModel)
# async def depend_on_either_json_or_form_data(model: Type[T]):
# return Depends(get_body)
def depend_on_either_json_or_form_data(model: type[B]) -> B:
async def get_body(request: Request):
content_type = request.headers.get("Content-Type")
if content_type is None:
raise HTTPException(status_code=400, detail="No Content-Type provided!")
elif content_type == "application/json":
try:
return model(**await request.json())
except JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON data")
elif content_type == "application/x-www-form-urlencoded" or content_type.startswith("multipart/form-data"):
try:
return model(**await request.form())
except Exception:
raise HTTPException(status_code=400, detail="Invalid Form data")
else:
raise HTTPException(status_code=400, detail="Content-Type not supported!")
return Depends(get_body)
UserIdPathParam: str = Path(..., title="User ID", description="The encoded database identifier of the user.")
RequiredRepoOwnerParam: str = Query(
title="owner",
description="Owner of the target repository.",
)
RequiredRepoNameParam: str = Query(
title="Name",
description="Name of the target repository.",
)
RequiredChangesetParam: str = Query(
title="changeset",
description="Changeset of the target repository.",
)
RepositoryIdPathParam: str = Path(
..., title="Repository ID", description="The encoded database identifier of the repository."
)
ChangesetRevisionPathParam: str = Path(
...,
title="Change Revision",
description="The changeset revision corresponding to the target revision of the target repository.",
)
UsernameIdPathParam: str = Path(..., title="Username", description="The target username.")
CommitMessageQueryParam: Optional[str] = Query(
default=None,
title="Commit Message",
description="Set commit message as a query parameter.",
)
DownloadableQueryParam: bool = Query(
default=True,
title="downloadable_only",
description="Include only downloadable repositories.",
)
CommitMessage: str = Query(
None,
title="Commit message",
description="A commit message to store with repository update.",
)
RepositoryIndexQueryParam: Optional[str] = Query(
default=None,
title="Search Query",
description="This will perform a full search with whoosh on the backend and will cause the API endpoint to return a RepositorySearchResult. This should not be used with the 'filter' parameter.",
)
RepositoryIndexFilterParam: Optional[str] = Query(
default=None,
title="Filter Text",
description="This will perform a quick search using database operators. This should not be used with the 'q' parameter.",
)
RepositoryIndexSortByParam: IndexSortByType = Query(
default="name",
title="Sort by",
description="Sort by the this repository field - direction is controlled by sort_desc that defaults to False and causes an ascending sort on this field. This field is ignored if 'q' is specified an whoosh search is used.",
)
RepositoryIndexSortDescParam: bool = Query(
default=False,
title="Sort Descending",
description="Direction of sort. This defaults to False and causes an ascending sort on the field specified by sort_on. This field is ignored if 'q' is specified an whoosh search is used.",
)
ToolsIndexQueryParam: str = Query(
default=...,
title="Search Query",
)
ToolSearchPageQueryParam: int = Query(
default=1,
title="Page",
description="",
)
RepositorySearchPageQueryParam: Optional[int] = Query(
default=None,
title="Page",
description="",
)
RepositorySearchPageSizeQueryParam: int = Query(
default=10,
title="Page Size",
)
RepositoryIndexDeletedQueryParam: Optional[bool] = Query(False, title="Deleted?")
RepositoryIndexOwnerQueryParam: Optional[str] = Query(None, title="Owner")
RepositoryIndexNameQueryParam: Optional[str] = Query(None, title="Name")
RepositoryIndexCategoryQueryParam: Optional[str] = Query(None, title="Category ID")
RepositoryIndexToolIdsQueryParam: Optional[list[str]] = Query(
None, title="Tool IDs", description="List of tool GUIDs to find the repository for"
)
OptionalRepositoryOwnerParam: Optional[str] = Query(None, title="Owner")
OptionalRepositoryNameParam: Optional[str] = Query(None, title="Name")
RequiredRepositoryChangesetRevisionParam: str = Query(..., title="Changeset Revision")
OptionalRepositoryIdParam: Optional[str] = Query(None, title="TSR ID")
OptionalHexlifyParam: Optional[bool] = Query(True, title="Hexlify response")
CategoryIdPathParam: str = Path(
..., title="Category ID", description="The encoded database identifier of the category."
)
CategoryRepositoriesInstallableQueryParam: bool = Query(False, title="Installable?")
CategoryRepositoriesSortKeyQueryParam: str = Query("name", title="Sort Key")
CategoryRepositoriesSortOrderQueryParam: str = Query("asc", title="Sort Order")
CategoryRepositoriesPageQueryParam: Optional[int] = Query(None, title="Page")
def ensure_valid_session(trans: SessionRequestContext) -> None:
"""
Ensure that a valid Galaxy session exists and is available as
trans.session (part of initialization)
"""
app = trans.app
mapping = app.model
session_manager = GalaxySessionManager(mapping)
sa_session = app.model.context
request = trans.request
# Try to load an existing session
galaxy_session = None
prev_galaxy_session = None
user_for_new_session = None
invalidate_existing_session = False
# Track whether the session has changed so we can avoid calling flush
# in the most common case (session exists and is valid).
galaxy_session_requires_flush = False
if secure_id := request.get_cookie(AUTH_COOKIE_NAME):
session_key: Optional[str] = app.security.decode_guid(secure_id)
if session_key:
# We do NOT catch exceptions here, if the database is down the request should fail,
# and we should not generate a new session.
galaxy_session = session_manager.get_session_from_session_key(session_key=session_key)
if not galaxy_session:
session_key = None
if galaxy_session is not None and galaxy_session.user is not None and galaxy_session.user.deleted:
invalidate_existing_session = True
log.warning(f"User '{galaxy_session.user.email}' is marked deleted, invalidating session")
# Do we need to invalidate the session for some reason?
if invalidate_existing_session:
assert galaxy_session
prev_galaxy_session = galaxy_session
prev_galaxy_session.is_valid = False
galaxy_session = None
# No relevant cookies, or couldn't find, or invalid, so create a new session
if galaxy_session is None:
galaxy_session = create_new_session(trans, prev_galaxy_session, user_for_new_session)
galaxy_session_requires_flush = True
trans.set_galaxy_session(galaxy_session)
set_auth_cookie(trans, galaxy_session)
else:
trans.set_galaxy_session(galaxy_session)
# Do we need to flush the session?
if galaxy_session_requires_flush:
sa_session.add(galaxy_session)
# FIXME: If prev_session is a proper relation this would not
# be needed.
if prev_galaxy_session:
sa_session.add(prev_galaxy_session)
sa_session.commit()
def set_auth_cookie(trans: SessionRequestContext, session):
cookie_name = AUTH_COOKIE_NAME
set_cookie(trans, trans.app.security.encode_guid(session.session_key), cookie_name)
def set_cookie(trans: SessionRequestContext, value: str, key, path="/", age=90) -> None:
"""Convenience method for setting a session cookie"""
# In wsgi we were setting both a max_age and and expires, but
# all browsers support max_age now.
domain: Optional[str] = trans.app.config.cookie_domain
trans.response.set_cookie(
key,
unicodify(value),
path=path,
max_age=3600 * 24 * age, # 90 days
httponly=True,
secure=trans.request.is_secure,
domain=domain,
)