diff --git a/.env.example b/.env.example index 8bf1758a..ebc70610 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ AUTH0_AUDIENCE=https://audience.com/api JWT_SECRET_KEY=secret-key # Note the list syntax pydantic-settings uses ADMIN_ROLES='["Admin", "GalaxyAdmin"]' +# URL of Galaxy instance, for making calls to Galaxy API +GALAXY_URL=https://galaxy.example.com +GALAXY_API_KEY=api-key # Comma-separated list of allowed origins. Note we # don't process this with pydantic-settings as it needs # to be used before the FastAPI app loads diff --git a/auth/config.py b/auth/config.py index 26f6e50b..2d0212a9 100644 --- a/auth/config.py +++ b/auth/config.py @@ -36,7 +36,7 @@ class Settings(BaseSettings): "bpa-wheat-pathogens-transcript": "Wheat Pathogens Transcript", } - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file=".env", extra="ignore") @lru_cache() diff --git a/galaxy/__init__.py b/galaxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/galaxy/client.py b/galaxy/client.py new file mode 100644 index 00000000..e4f2bba1 --- /dev/null +++ b/galaxy/client.py @@ -0,0 +1,40 @@ +from typing import Annotated + +from fastapi import Depends +from httpx import Client + +from galaxy.config import GalaxySettings, get_galaxy_settings +from galaxy.schemas import GalaxyUserModel + + +class GalaxyClient: + """ + Client for making requests to the Galaxy API. + """ + + def __init__(self, galaxy_url: str, api_key: str): + """ + :param galaxy_url: Base URL of the Galaxy instance. + :param api_key: API key for a galaxy user. + """ + self.url = galaxy_url + self.api_key = api_key + self.client = Client(base_url=self.url, headers={'x-api-key': self.api_key}) + + def username_exists(self, username: str) -> bool: + """ + Check if a username already exists in Galaxy. + Note the user search in the current Galaxy API will return + partial matches, so we need to check the returned users + for an exact match. + """ + resp = self.client.get("/api/users", params={"f_name": username}) + returned_users = [GalaxyUserModel(**u) for u in resp.json()] + for user in returned_users: + if user.username == username: + return True + return False + + +def get_galaxy_client(settings: Annotated[GalaxySettings, Depends(get_galaxy_settings)]): + return GalaxyClient(galaxy_url=settings.galaxy_url, api_key=settings.galaxy_api_key) diff --git a/galaxy/config.py b/galaxy/config.py new file mode 100644 index 00000000..3896e642 --- /dev/null +++ b/galaxy/config.py @@ -0,0 +1,21 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GalaxySettings(BaseSettings): + """ + Settings for the Galaxy API. + + Note: these are currently read from the same .env file + as other settings (which pydantic-settings supports). + """ + galaxy_url: str + galaxy_api_key: str + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + +@lru_cache +def get_galaxy_settings(): + return GalaxySettings() diff --git a/galaxy/schemas.py b/galaxy/schemas.py new file mode 100644 index 00000000..4bf0663e --- /dev/null +++ b/galaxy/schemas.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, NaiveDatetime + + +class GalaxyUserModel(BaseModel): + """ + User data returned by Galaxy's users API + """ + model_class: str + id: str + username: str + email: str + deleted: bool + active: bool + last_password_change: Optional[NaiveDatetime] diff --git a/routers/galaxy_register.py b/routers/galaxy_register.py index fdea9ddd..926f9481 100644 --- a/routers/galaxy_register.py +++ b/routers/galaxy_register.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Annotated, Optional import httpx from fastapi import APIRouter, Header, HTTPException @@ -7,6 +7,7 @@ from auth.config import Settings, get_settings from auth.management import get_management_token +from galaxy.client import GalaxyClient, get_galaxy_client from register.tokens import create_registration_token, verify_registration_token from schemas.biocommons import BiocommonsRegisterData from schemas.galaxy import GalaxyRegistrationData @@ -26,8 +27,9 @@ async def get_registration_token(settings: Settings = Depends(get_settings)): @router.post("/register") def register( registration_data: GalaxyRegistrationData, + settings: Annotated[Settings, Depends(get_settings)], + galaxy_client: Annotated[GalaxyClient, Depends(get_galaxy_client)], registration_token: Optional[str] = Header(None), - settings: Settings = Depends(get_settings), ): if not registration_token: raise HTTPException(status_code=400, detail="Missing registration token") @@ -35,11 +37,20 @@ def register( verify_registration_token(registration_token, settings=settings) logger.debug("Registration token verified.") + user_data = BiocommonsRegisterData.from_galaxy_registration(registration_data) + logger.debug("Checking if username exists in Galaxy") + galaxy_username = user_data.user_metadata.galaxy_username + try: + existing = galaxy_client.username_exists(galaxy_username) + if existing: + raise HTTPException(status_code=400, detail="Username already exists") + except httpx.HTTPError as e: + logger.warning(f"Failed to check username in Galaxy: {e}") + url = f"https://{settings.auth0_domain}/api/v2/users" logger.debug("Getting management token.") management_token = get_management_token(settings=settings) headers = {"Authorization": f"Bearer {management_token}"} - user_data = BiocommonsRegisterData.from_galaxy_registration(registration_data) logger.debug("Registering with Auth0 management API") resp = httpx.post( url, diff --git a/tests/conftest.py b/tests/conftest.py index 077fe7d9..5fc716ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,13 @@ +from unittest.mock import MagicMock + import pytest from fastapi.testclient import TestClient from auth.config import Settings, get_settings from auth.management import get_management_token from auth.validator import get_current_user +from galaxy.client import GalaxyClient, get_galaxy_client +from galaxy.config import GalaxySettings, get_galaxy_settings from main import app from tests.datagen import AccessTokenPayloadFactory, SessionUserFactory @@ -16,7 +20,10 @@ def ignore_env_file(): """ def get_settings_no_env_file(): return Settings(_env_file=None) + def get_galaxy_settings_no_env_file(): + return GalaxySettings(_env_file=None) app.dependency_overrides[get_settings] = get_settings_no_env_file + app.dependency_overrides[get_galaxy_settings] = get_galaxy_settings_no_env_file @pytest.fixture @@ -33,8 +40,15 @@ def mock_settings(): auth0_algorithms=["HS256"] ) + +@pytest.fixture +def mock_galaxy_settings(): + """Dummy settings values for GalaxySettings""" + return GalaxySettings(galaxy_url="http://mock-url", galaxy_api_key="mock-key") + + @pytest.fixture -def test_client(mock_settings): +def test_client(mock_settings, mock_galaxy_settings): """ Override the get_settings dependency to return a mocked Settings object. """ @@ -44,6 +58,7 @@ def override_settings(): # Apply override app.dependency_overrides[get_settings] = override_settings + app.dependency_overrides[get_galaxy_settings] = lambda: mock_galaxy_settings # Create client client = TestClient(app) @@ -67,3 +82,11 @@ def override_user(): app.dependency_overrides[get_management_token] = lambda: "mock_token" yield app.dependency_overrides.clear() + + +@pytest.fixture +def mock_galaxy_client(): + client = MagicMock(GalaxyClient) + app.dependency_overrides[get_galaxy_client] = lambda: client + yield client + app.dependency_overrides.clear() diff --git a/tests/galaxy/__init__.py b/tests/galaxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/galaxy/data_generation.py b/tests/galaxy/data_generation.py new file mode 100644 index 00000000..9c3f6527 --- /dev/null +++ b/tests/galaxy/data_generation.py @@ -0,0 +1,6 @@ +from polyfactory.factories.pydantic_factory import ModelFactory + +from galaxy.schemas import GalaxyUserModel + + +class GalaxyUserFactory(ModelFactory[GalaxyUserModel]): ... diff --git a/tests/galaxy/test_client.py b/tests/galaxy/test_client.py new file mode 100644 index 00000000..9439c4d9 --- /dev/null +++ b/tests/galaxy/test_client.py @@ -0,0 +1,25 @@ +import httpx +import pytest + +from galaxy.client import GalaxyClient +from tests.galaxy.data_generation import GalaxyUserFactory + + +@pytest.fixture +def galaxy_client(): + return GalaxyClient(galaxy_url="https://galaxy.example.com", + api_key="dummy-key") + + +def test_username_exists(galaxy_client, respx_mock): + user1 = GalaxyUserFactory.build(username="user1") + user2 = GalaxyUserFactory.build(username="user2") + respx_mock.get("https://galaxy.example.com/api/users").mock( + return_value=httpx.Response( + 200, + json=[user1.model_dump(mode="json"), + user2.model_dump(mode="json")] + ) + ) + assert galaxy_client.username_exists("user1") + assert not galaxy_client.username_exists("other_user") diff --git a/tests/test_galaxy.py b/tests/test_galaxy.py index a04012ef..24c0435f 100644 --- a/tests/test_galaxy.py +++ b/tests/test_galaxy.py @@ -138,7 +138,7 @@ def test_register(mocker, mock_auth_token, mock_settings, test_client): @pytest.mark.respx(base_url="https://mock-domain") -def test_register_json_types(respx_mock, mock_auth_token, mock_settings, test_client): +def test_register_json_types(respx_mock, mock_auth_token, mock_settings, test_client, mock_galaxy_client): """ Test how we handle datetimes in the response data: if we don't use model_dump(mode="json") when providing json data, we can get errors @@ -154,6 +154,7 @@ def test_register_json_types(respx_mock, mock_auth_token, mock_settings, test_cl user_data = GalaxyRegistrationDataFactory.build() token_resp = test_client.get("/galaxy/get-registration-token") headers = {"registration-token": token_resp.json()["token"]} + mock_galaxy_client.username_exists.return_value = False resp = test_client.post("/galaxy/register", json=user_data.model_dump(), headers=headers) assert resp.status_code == 200