From bbf46ba2f4c709b1dedbb0cc4376fc0d586dbfa0 Mon Sep 17 00:00:00 2001 From: Patrick Austin Date: Thu, 25 Jun 2026 16:35:26 +0000 Subject: [PATCH 1/2] Config changes for read only API endpoints, placeholder GET investigations --- .gitignore | 1 + datagateway_api/common/config.py | 43 ++++--- datagateway_api/config.yaml.example | 22 ++-- .../datagateway_api/icat/filters.py | 8 +- .../datagateway_api/icat/helpers.py | 8 +- .../datagateway_api/icat/icat_client_pool.py | 16 +-- .../datagateway_api/icat/lru_cache.py | 2 +- .../icat/reader_query_handler.py | 16 ++- datagateway_api/main.py | 20 ++++ .../read_only_api/models/investigation.py | 110 ++++++++++++++++++ .../read_only_api/routers/my_data.py | 36 ++++++ datagateway_api/search_api/session_handler.py | 2 +- test/integration/conftest.py | 4 +- .../icat/filters/test_skip_filter.py | 4 +- .../datagateway_api/icat/test_icat_client.py | 43 ------- .../datagateway_api/icat/test_lru_cache.py | 8 +- .../icat/test_reader_performance.py | 21 ++-- .../icat/test_session_handling.py | 4 +- .../filters/test_search_api_skip_filter.py | 4 +- .../search_api/test_session_handler.py | 2 +- test/unit/search_api/test_panosc_mappings.py | 42 +++---- util/icat_db_generator.py | 4 +- util/setup_v11_1_0.py | 2 +- 23 files changed, 266 insertions(+), 156 deletions(-) create mode 100644 datagateway_api/read_only_api/models/investigation.py create mode 100644 datagateway_api/read_only_api/routers/my_data.py diff --git a/.gitignore b/.gitignore index 44eae0e5..8a4a5a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.venv venv/ .idea/ *.pyc diff --git a/datagateway_api/common/config.py b/datagateway_api/common/config.py index c20c6f61..3146259f 100644 --- a/datagateway_api/common/config.py +++ b/datagateway_api/common/config.py @@ -7,6 +7,7 @@ from pydantic import ( AfterValidator, BaseModel, + PositiveInt, computed_field, Field, model_validator, @@ -46,11 +47,10 @@ def validate_extension(extension): DataGatewayAPIExtension = Annotated[StrictStr, AfterValidator(validate_extension)] -class UseReaderForPerformance(BaseModel): - enabled: StrictBool - reader_mechanism: StrictStr - reader_username: StrictStr - reader_password: SecretStr +class ReaderConfig(BaseModel): + mechanism: StrictStr + username: StrictStr + password: SecretStr maxsize: int = Field( default=128, description="Each cacheable function will store up to this many results in memory.", @@ -61,24 +61,41 @@ class UseReaderForPerformance(BaseModel): ) +class IcatConfig(BaseModel): + url: StrictStr + check_cert: StrictBool + client_cache_size: StrictInt + client_pool_init_size: StrictInt + client_pool_max_size: StrictInt + reader: ReaderConfig | None = None + + class DataGatewayAPI(BaseModel): """ Configuration model class that implements pydantic's BaseModel class to allow for validation of the DataGatewayAPI config data using Python type annotations. """ - client_cache_size: StrictInt - client_pool_init_size: StrictInt - client_pool_max_size: StrictInt extension: DataGatewayAPIExtension - icat_check_cert: StrictBool - icat_url: StrictStr - use_reader_for_performance: Optional[UseReaderForPerformance] = None def __getitem__(self, item): return getattr(self, item) +class LimitConfig(BaseModel): + default: PositiveInt = 100 + maximum: PositiveInt = 100 + + @model_validator(mode="after") + def _validate(self) -> Self: + if self.default > self.maximum: + raise ValueError("default limit cannot exceed maximum limit") + + +class ReadOnlyAPI(DataGatewayAPI): + limit: LimitConfig = LimitConfig() + + class SearchScoring(BaseModel): enabled: StrictBool api_url: StrictStr @@ -94,8 +111,6 @@ class SearchAPI(BaseModel): """ extension: DataGatewayAPIExtension - icat_check_cert: StrictBool - icat_url: StrictStr mechanism: StrictStr username: StrictStr password: StrictStr @@ -131,7 +146,9 @@ class APIConfig(BaseModel): API startup so any missing options will be caught quickly. """ + icat: IcatConfig datagateway_api: Optional[DataGatewayAPI] = None + read_only_api: DataGatewayAPI | None = None reload: Optional[StrictBool] = None host: Optional[StrictStr] = None port: Optional[StrictInt] = None diff --git a/datagateway_api/config.yaml.example b/datagateway_api/config.yaml.example index 9ef2fc56..bc21ada0 100644 --- a/datagateway_api/config.yaml.example +++ b/datagateway_api/config.yaml.example @@ -1,20 +1,20 @@ --- -datagateway_api: - extension: "/datagateway-api" +icat: + url: "https://localhost:8181" + check_cert: false client_cache_size: 5 client_pool_init_size: 2 client_pool_max_size: 5 - icat_url: "https://localhost:8181" - icat_check_cert: false - use_reader_for_performance: - enabled: false - reader_mechanism: simple - reader_username: reader - reader_password: readerpw +# reader: +# mechanism: simple +# username: reader +# password: readerpw +datagateway_api: + extension: "/datagateway-api" +read_only_api: + extension: "/read-only-api" search_api: extension: "/search-api" - icat_url: "https://localhost:8181" - icat_check_cert: false mechanism: "anon" username: "" password: "" diff --git a/datagateway_api/datagateway_api/icat/filters.py b/datagateway_api/datagateway_api/icat/filters.py index 639684bc..8ecc65ea 100644 --- a/datagateway_api/datagateway_api/icat/filters.py +++ b/datagateway_api/datagateway_api/icat/filters.py @@ -251,13 +251,13 @@ def __init__(self, skip_value, filter_use="datagateway_api"): def apply_filter(self, query): if self.filter_use == "datagateway_api": icat_properties = get_icat_properties( - Config.config.datagateway_api.icat_url, - Config.config.datagateway_api.icat_check_cert, + Config.config.icat.url, + Config.config.icat.check_cert, ) else: icat_properties = get_icat_properties( - Config.config.search_api.icat_url, - Config.config.search_api.icat_check_cert, + Config.config.icat.url, + Config.config.icat.check_cert, ) icat_set_limit(query, self.skip_value, icat_properties["maxEntities"]) diff --git a/datagateway_api/datagateway_api/icat/helpers.py b/datagateway_api/datagateway_api/icat/helpers.py index 21387752..fc8ffa9c 100644 --- a/datagateway_api/datagateway_api/icat/helpers.py +++ b/datagateway_api/datagateway_api/icat/helpers.py @@ -417,13 +417,7 @@ def is_use_reader_for_performance_enabled() -> bool: Returns true is the 'use_reader_for_performance' section is present in the config file and 'enabled' in that section is set to true """ - reader_config = Config.config.datagateway_api.use_reader_for_performance - if not reader_config: - return False - if not reader_config.enabled: - return False - - return True + return Config.config.icat.reader is not None def get_first_result_with_filters(client, entity_type, filters): diff --git a/datagateway_api/datagateway_api/icat/icat_client_pool.py b/datagateway_api/datagateway_api/icat/icat_client_pool.py index ec330b86..baf250e9 100644 --- a/datagateway_api/datagateway_api/icat/icat_client_pool.py +++ b/datagateway_api/datagateway_api/icat/icat_client_pool.py @@ -11,16 +11,8 @@ class ICATClient(Client): """Wrapper class to allow an object pool of client objects to be created""" - def __init__(self, client_use="datagateway_api"): - if client_use == "datagateway_api": - icat_url = Config.config.datagateway_api.icat_url - icat_check_cert = Config.config.datagateway_api.icat_check_cert - else: - # Search API use cases - icat_url = Config.config.search_api.icat_url - icat_check_cert = Config.config.search_api.icat_check_cert - - super().__init__(icat_url, checkCert=icat_check_cert) + def __init__(self): + super().__init__(Config.config.icat.url, checkCert=Config.config.icat.check_cert) # When clients are cleaned up, sessions won't be logged out self.autoLogout = False @@ -41,8 +33,8 @@ def create_client_pool(): return ObjectPool( ICATClient, - min_init=Config.config.datagateway_api.client_pool_init_size, - max_capacity=Config.config.datagateway_api.client_pool_max_size, + min_init=Config.config.icat.client_pool_init_size, + max_capacity=Config.config.icat.client_pool_max_size, max_reusable=0, expires=0, ) diff --git a/datagateway_api/datagateway_api/icat/lru_cache.py b/datagateway_api/datagateway_api/icat/lru_cache.py index 8b584cae..d93714c6 100644 --- a/datagateway_api/datagateway_api/icat/lru_cache.py +++ b/datagateway_api/datagateway_api/icat/lru_cache.py @@ -19,7 +19,7 @@ class ExtendedLRUCache(LRUCache): """ def __init__(self): - super().__init__(maxsize=Config.config.datagateway_api.client_cache_size) + super().__init__(maxsize=Config.config.icat.client_cache_size) def popitem(self): key, client = super().popitem() diff --git a/datagateway_api/datagateway_api/icat/reader_query_handler.py b/datagateway_api/datagateway_api/icat/reader_query_handler.py index ba0dee48..7e550293 100644 --- a/datagateway_api/datagateway_api/icat/reader_query_handler.py +++ b/datagateway_api/datagateway_api/icat/reader_query_handler.py @@ -47,9 +47,9 @@ class ReaderQueryHandler: reader_client = None maxsize = 128 # cachetools default value ttl = 600 # seconds, cachetools default value - if Config.config.datagateway_api.use_reader_for_performance is not None: - maxsize = Config.config.datagateway_api.use_reader_for_performance.maxsize - ttl = Config.config.datagateway_api.use_reader_for_performance.ttl + if Config.config.icat.reader is not None: + maxsize = Config.config.icat.reader.maxsize + ttl = Config.config.icat.reader.ttl def __init__(self, entity_type: str, filters: List[QueryFilter]) -> None: self.entity_type = entity_type @@ -70,15 +70,13 @@ def create_reader_client(cls) -> ICATClient: raised (resulting in a 500). The client object is returned """ log.info("Creating reader_client") - cls.reader_client = ICATClient("datagateway_api") + cls.reader_client = ICATClient() try: cls.reader_client.login( - auth=Config.config.datagateway_api.use_reader_for_performance.reader_mechanism, + auth=Config.config.icat.reader.mechanism, credentials={ - "username": Config.config.datagateway_api.use_reader_for_performance.reader_username, - "password": ( - Config.config.datagateway_api.use_reader_for_performance.reader_password.get_secret_value() - ), + "username": Config.config.icat.reader.username, + "password": Config.config.icat.reader.password.get_secret_value(), }, ) except ICATSessionError as e: diff --git a/datagateway_api/main.py b/datagateway_api/main.py index 54df036f..46504057 100644 --- a/datagateway_api/main.py +++ b/datagateway_api/main.py @@ -9,9 +9,11 @@ from datagateway_api.common.config import Config from datagateway_api.common.exceptions import ApiError from datagateway_api.common.logger_setup import LOGGING_CONFIG_FILE_PATH, setup_logger +from datagateway_api.read_only_api.routers.my_data import my_data_endpoints # Check which APIs are enabled datagateway_api_enabled = Config.config.datagateway_api is not None +read_only_api_enabled = Config.config.read_only_api is not None search_api_enabled = Config.config.search_api is not None if datagateway_api_enabled: @@ -107,6 +109,18 @@ def create_datagateway_app() -> FastAPI | None: return None +def create_read_only_app() -> FastAPI | None: + read_only_app = FastAPI(title="Read Only API") + enable_cors(read_only_app) + register_common_handlers(read_only_app) + python_icat = PythonICAT() + icat_client_pool = create_client_pool() + read_only_app.include_router(my_data_endpoints(python_icat=python_icat, client_pool=icat_client_pool)) + read_only_app.include_router(ping_endpoint(python_icat, client_pool=icat_client_pool)) + read_only_app.include_router(sessions_endpoints(python_icat, client_pool=icat_client_pool)) + return read_only_app + + def create_search_api_app() -> FastAPI | None: if search_api_enabled: search_api_app = FastAPI( @@ -137,6 +151,8 @@ def create_search_api_app() -> FastAPI | None: ) if datagateway_api_enabled: app.mount(path=Config.config.datagateway_api.extension, app=create_datagateway_app()) + if read_only_api_enabled: + app.mount(path=Config.config.read_only_api.extension, app=create_read_only_app()) if search_api_enabled: app.mount(path=Config.config.search_api.extension, app=create_search_api_app()) @@ -144,6 +160,10 @@ def create_search_api_app() -> FastAPI | None: app = create_datagateway_app() app.root_path = f"{Config.config.url_prefix}{Config.config.datagateway_api.extension}" +elif read_only_api_enabled: + app = create_read_only_app() + app.root_path = f"{Config.config.url_prefix}{Config.config.read_only_api.extension}" + elif search_api_enabled: app = create_search_api_app() app.root_path = f"{Config.config.url_prefix}{Config.config.search_api.extension}" diff --git a/datagateway_api/read_only_api/models/investigation.py b/datagateway_api/read_only_api/models/investigation.py new file mode 100644 index 00000000..bc1eed7a --- /dev/null +++ b/datagateway_api/read_only_api/models/investigation.py @@ -0,0 +1,110 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel + + +class Instrument(BaseModel): + name: str + fullName: str | None = None + description: str | None = None + type: str | None = None + url: str | None = None + pid: str | None = None + startDate: datetime | None = None + endDate: datetime | None = None + + +class InvestigationInstrument(BaseModel): + instrument: Instrument + + +class User(BaseModel): + name: str + fullName: str | None = None + givenName: str | None = None + familyName: str | None = None + email: str | None = None + affiliation: str | None = None + orcid: str | None = None + + +class InvestigationUser(BaseModel): + role: str + + user: User + + +class SampleType(BaseModel): + name: str + molecularFormula: str + safetyInformation: str | None = None + + +class Sample(BaseModel): + name: str + pid: str | None = None + + type: SampleType + + +class ParameterValueType(StrEnum): + STRING = "STRING" + DATE_AND_TIME = "DATE_AND_TIME" + NUMERIC = "NUMERIC" + + +class ParameterType(BaseModel): + name: str + valueType: ParameterValueType + units: str + unitsFullName: str | None = None + pid: str | None = None + description: str | None = None + minimumNumericValue: float | None = None + maximumNumericValue: float | None = None + enforced: bool | None = None + verified: bool | None = None + applicableToInvestigation: bool | None = None + applicableToDataset: bool | None = None + applicableToDatafile: bool | None = None + applicableToSample: bool | None = None + applicableToDataCollection: bool | None = None + + +class Parameter(BaseModel): + stringValue: str | None = None + dateTimeValue: datetime | None = None + numericValue: float | None = None + error: float | None = None + rangeBottom: float | None = None + rangeTop: float | None = None + + type: ParameterType + + +class Publication(BaseModel): + fullReference: str + doi: str | None = None + url: str | None = None + repository: str | None = None + repositoryId: str | None = None + + +class Investigation(BaseModel): + name: str + visitId: str + title: str + summary: str | None = None + doi: str | None = None + startDate: datetime | None = None + endDate: datetime | None = None + releaseDate: datetime | None = None + fileCount: int | None = None + fileSize: int | None = None + + investigationInstruments: list[InvestigationInstrument] + investigationUsers: list[InvestigationUser] | None = None + samples: list[Sample] | None = None + parameters: list[Parameter] | None = None + publications: list[Publication] | None = None diff --git a/datagateway_api/read_only_api/routers/my_data.py b/datagateway_api/read_only_api/routers/my_data.py new file mode 100644 index 00000000..f11ee4ed --- /dev/null +++ b/datagateway_api/read_only_api/routers/my_data.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from object_pool import ObjectPool + +from datagateway_api.datagateway_api.icat.python_icat import PythonICAT +from datagateway_api.read_only_api.models.investigation import Investigation + + +security = HTTPBearer() + + +def my_data_endpoints(python_icat: PythonICAT, icat_client_pool: ObjectPool) -> APIRouter: + router = APIRouter(prefix="/my_data", tags="My data") + + @router.get( + "/investigations", + summary="Get investigations", + description="Get investigations", + response_model=list[Investigation], + responses={ + 200: {"description": f"Success - returns Investigations that satisfy the filters"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_investigations(credentials: HTTPAuthorizationCredentials = Depends(security)): + return python_icat.get_with_filters( + session_id=credentials.credentials, + entity_type="Investigaton", + filters=[], + icat_client_pool=icat_client_pool, + ) + + return router diff --git a/datagateway_api/search_api/session_handler.py b/datagateway_api/search_api/session_handler.py index 2004281c..0b206f12 100644 --- a/datagateway_api/search_api/session_handler.py +++ b/datagateway_api/search_api/session_handler.py @@ -16,7 +16,7 @@ class SessionHandler: anon user """ - client = ICATClient(client_use="search_api") + client = ICATClient() def client_manager(method): diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 09bf43ff..59857ba2 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -16,8 +16,8 @@ @pytest.fixture(scope="package") def icat_client(): client = Client( - Config.config.datagateway_api.icat_url, - checkCert=Config.config.datagateway_api.icat_check_cert, + Config.config.icat.url, + checkCert=Config.config.icat.check_cert, ) client.login( Config.config.test_mechanism, diff --git a/test/integration/datagateway_api/icat/filters/test_skip_filter.py b/test/integration/datagateway_api/icat/filters/test_skip_filter.py index 8bba7718..f01dfa45 100644 --- a/test/integration/datagateway_api/icat/filters/test_skip_filter.py +++ b/test/integration/datagateway_api/icat/filters/test_skip_filter.py @@ -18,8 +18,8 @@ def test_valid_skip_value(self, icat_query, skip_value): assert icat_query.limit == ( skip_value, get_icat_properties( - Config.config.datagateway_api.icat_url, - Config.config.datagateway_api.icat_check_cert, + Config.config.icat.url, + Config.config.icat.check_cert, )["maxEntities"], ) diff --git a/test/integration/datagateway_api/icat/test_icat_client.py b/test/integration/datagateway_api/icat/test_icat_client.py index fe2907f6..764e4d08 100644 --- a/test/integration/datagateway_api/icat/test_icat_client.py +++ b/test/integration/datagateway_api/icat/test_icat_client.py @@ -13,49 +13,6 @@ def test_init(self): assert not test_icat_client.autoLogout - @pytest.mark.parametrize( - "client_use, expected_url, expected_check_cert", - [ - pytest.param( - "datagateway_api", - "https://localhost:8181/ICATService/ICAT?wsdl", - False, - id="DataGateway API Usage", - ), - pytest.param( - "search_api", - "https://localhost.testdomain:8181/ICATService/ICAT?wsdl", - True, - id="Search API Usage", - ), - ], - ) - def test_client_use( - self, - test_config, - client_use, - expected_url, - expected_check_cert, - ): - class MockClient: - def __init__(url, checkCert=True): # noqa - # Would've preferred to assign these values to self but this didn't - # seem to be possible - Client.url = f"{url}/ICATService/ICAT?wsdl" - Client.checkCert = checkCert - - with patch( - "datagateway_api.common.config.Config.config", - test_config, - ): - with patch( - "icat.client.Client.__init__", - side_effect=MockClient.__init__, - ): - test_icat_client = ICATClient(client_use) - assert test_icat_client.url == expected_url - assert test_icat_client.checkCert == expected_check_cert - def test_clean_up(self): test_icat_client = ICATClient() assert id(test_icat_client) in Client.Register diff --git a/test/integration/datagateway_api/icat/test_lru_cache.py b/test/integration/datagateway_api/icat/test_lru_cache.py index 8186a07c..bd578199 100644 --- a/test/integration/datagateway_api/icat/test_lru_cache.py +++ b/test/integration/datagateway_api/icat/test_lru_cache.py @@ -11,14 +11,14 @@ class TestLRUCache: def test_valid_cache_creation(self): test_cache = ExtendedLRUCache() - assert test_cache.maxsize == Config.config.datagateway_api.client_cache_size + assert test_cache.maxsize == Config.config.icat.client_cache_size def test_valid_popitem(self): test_cache = ExtendedLRUCache() test_pool = create_client_pool() test_client = Client( - Config.config.datagateway_api.icat_url, - checkCert=Config.config.datagateway_api.icat_check_cert, + Config.config.icat.url, + checkCert=Config.config.icat.check_cert, ) test_cache.popitem = MagicMock(side_effect=test_cache.popitem) @@ -27,7 +27,7 @@ def test_valid_popitem(self): def get_cached_client(cache_number, client_pool): return test_client - for cache_number in range(Config.config.datagateway_api.client_cache_size + 1): + for cache_number in range(Config.config.icat.client_cache_size + 1): get_cached_client(cache_number, test_pool) assert test_cache.popitem.called diff --git a/test/integration/datagateway_api/icat/test_reader_performance.py b/test/integration/datagateway_api/icat/test_reader_performance.py index 122c58b9..23c7a0b2 100644 --- a/test/integration/datagateway_api/icat/test_reader_performance.py +++ b/test/integration/datagateway_api/icat/test_reader_performance.py @@ -5,7 +5,7 @@ from icat.client import Client import pytest -from datagateway_api.common.config import APIConfig, Config +from datagateway_api.common.config import APIConfig, Config, ReaderConfig from datagateway_api.common.exceptions import MissingRecordError, PythonICATError from datagateway_api.datagateway_api.icat.filters import ( PythonICATLimitFilter, @@ -103,29 +103,28 @@ def associate_data_publication(icat_client: Client) -> Generator[None, None, Non @pytest.fixture(scope="class") def icat_user_client() -> Client: - client = Client(url=Config.config.datagateway_api.icat_url, checkCert=Config.config.datagateway_api.icat_check_cert) + client = Client(url=Config.config.icat.url, checkCert=Config.config.icat.check_cert) client.login(auth="simple", credentials={"username": "icatuser", "password": "icatuserpw"}) return client @pytest.fixture(scope="class") def icat_root_client() -> Client: - client = Client(url=Config.config.datagateway_api.icat_url, checkCert=Config.config.datagateway_api.icat_check_cert) + client = Client(url=Config.config.icat.url, checkCert=Config.config.icat.check_cert) client.login(auth="simple", credentials={"username": "root", "password": "pw"}) return client @pytest.fixture(scope="function") def enable_reader_config() -> Generator[None, None, None]: - Config.config.datagateway_api.use_reader_for_performance.enabled = True + Config.config.icat.reader = ReaderConfig(mechanism="simple", username="reader", password="readerpw") yield Config.config = APIConfig.load() @pytest.fixture(scope="function") def enable_reader_bad_config() -> Generator[None, None, None]: - Config.config.datagateway_api.use_reader_for_performance.enabled = True - Config.config.datagateway_api.use_reader_for_performance.reader_mechanism = "bad" + Config.config.icat.reader = ReaderConfig(mechanism="bad", username="reader", password="readerpw") yield Config.config = APIConfig.load() @@ -190,7 +189,7 @@ def test_reader_client(self, enable_reader_config: None): reader_client = ReaderQueryHandler.reader_client assert isinstance(reader_client, ICATClient) assert reader_client.getUserName() == ( - f"{Config.config.datagateway_api.use_reader_for_performance.reader_mechanism}/{Config.config.datagateway_api.use_reader_for_performance.reader_username}" + f"{Config.config.icat.reader.mechanism}/{Config.config.icat.reader.username}" ) @pytest.mark.parametrize( @@ -260,8 +259,8 @@ def test_root_access( def test_refresh(self, enable_reader_config: None) -> None: client = Client( - url=Config.config.datagateway_api.icat_url, - checkCert=Config.config.datagateway_api.icat_check_cert, + url=Config.config.icat.url, + checkCert=Config.config.icat.check_cert, ) client._next_refresh = 0 ReaderQueryHandler.reader_client = client @@ -271,8 +270,8 @@ def test_refresh(self, enable_reader_config: None) -> None: def test_refresh_failure(self, enable_reader_bad_config: None) -> None: client = Client( - url=Config.config.datagateway_api.icat_url, - checkCert=Config.config.datagateway_api.icat_check_cert, + url=Config.config.icat.url, + checkCert=Config.config.icat.check_cert, ) client._next_refresh = 0 ReaderQueryHandler.reader_client = client diff --git a/test/integration/datagateway_api/icat/test_session_handling.py b/test/integration/datagateway_api/icat/test_session_handling.py index bc47f28d..f985a13e 100644 --- a/test/integration/datagateway_api/icat/test_session_handling.py +++ b/test/integration/datagateway_api/icat/test_session_handling.py @@ -164,8 +164,8 @@ def test_expired_session(self): def test_valid_logout(self, test_client): client = Client( - Config.config.datagateway_api.icat_url, - checkCert=Config.config.datagateway_api.icat_check_cert, + Config.config.icat.url, + checkCert=Config.config.icat.check_cert, ) client.login( Config.config.test_mechanism, diff --git a/test/integration/search_api/filters/test_search_api_skip_filter.py b/test/integration/search_api/filters/test_search_api_skip_filter.py index df959fc8..32db0020 100644 --- a/test/integration/search_api/filters/test_search_api_skip_filter.py +++ b/test/integration/search_api/filters/test_search_api_skip_filter.py @@ -23,8 +23,8 @@ def test_valid_skip_value(self, search_api_query_document, skip_value): assert search_api_query_document.icat_query.query.limit == ( int(skip_value), get_icat_properties( - Config.config.search_api.icat_url, - Config.config.search_api.icat_check_cert, + Config.config.icat.url, + Config.config.icat.check_cert, )["maxEntities"], ) diff --git a/test/integration/search_api/test_session_handler.py b/test/integration/search_api/test_session_handler.py index 18bc5ac4..c0246a09 100644 --- a/test/integration/search_api/test_session_handler.py +++ b/test/integration/search_api/test_session_handler.py @@ -8,7 +8,7 @@ class TestSessionHandler: def setup_method(self): # Recreate client before each test - SessionHandler.client = ICATClient(client_use="search_api") + SessionHandler.client = ICATClient() def test_session_handler_class(self): assert isinstance(SessionHandler.client, ICATClient) diff --git a/test/unit/search_api/test_panosc_mappings.py b/test/unit/search_api/test_panosc_mappings.py index 1b15cb7b..d7645bdb 100644 --- a/test/unit/search_api/test_panosc_mappings.py +++ b/test/unit/search_api/test_panosc_mappings.py @@ -1,44 +1,30 @@ -from unittest.mock import patch +from typing import Generator import pytest +from datagateway_api.common.config import APIConfig, Config from datagateway_api.common.exceptions import FilterError, SearchAPIError from datagateway_api.search_api.panosc_mappings import PaNOSCMappings +@pytest.fixture(scope="function") +def disable_search_api() -> Generator[None, None, None]: + Config.config.search_api = None + yield + Config.config = APIConfig.load() + + class TestPaNOSCMappings: def test_valid_load_mappings(self, test_panosc_mappings): test_mappings = PaNOSCMappings() assert test_mappings.mappings == test_panosc_mappings.mappings - @pytest.mark.parametrize( - "search_api_config_flag", - [ - pytest.param(True, id="Search API config present"), - pytest.param(False, id="No search API config"), - ], - ) - def test_invalid_load_mappings( - self, - test_config, - test_config_without_search_api, - search_api_config_flag, - ): - if search_api_config_flag: - current_test_config = test_config - else: - current_test_config = test_config_without_search_api + def test_panosc_mappings_enabled_bad_path(self) -> None: + with pytest.raises(SystemExit, match="An error occurred while trying to load the PaNOSC mappings:"): + PaNOSCMappings("bad/path") - with patch( - "datagateway_api.common.config.Config.config", - current_test_config, - ): - if search_api_config_flag: - with pytest.raises(SystemExit): - PaNOSCMappings("bad/path") - else: - # Shouldn't SysExit if a user isn't using the search API - PaNOSCMappings("bad/path") + def test_panosc_mappings_disabled_bad_path(self, disable_search_api: None) -> None: + PaNOSCMappings("bad/path") # Shouldn't SysExit if a user isn't using the search API @pytest.mark.parametrize( "panosc_entity_name, field_name, expected_panosc_entity_name, expected_icat_field_name", diff --git a/util/icat_db_generator.py b/util/icat_db_generator.py index 91805a30..f43ab860 100644 --- a/util/icat_db_generator.py +++ b/util/icat_db_generator.py @@ -95,8 +95,8 @@ def apply_common_parameter_attributes(entity, i, client): def icat_client(): client = Client( - Config.config.datagateway_api.icat_url, - checkCert=Config.config.datagateway_api.icat_check_cert, + Config.config.icat.url, + checkCert=Config.config.icat.check_cert, ) client.login( Config.config.test_mechanism, diff --git a/util/setup_v11_1_0.py b/util/setup_v11_1_0.py index 96df5537..db107db6 100644 --- a/util/setup_v11_1_0.py +++ b/util/setup_v11_1_0.py @@ -97,7 +97,7 @@ def setup() -> None: (grouping,) = client.search( query=( "SELECT ug.grouping FROM UserGroup ug WHERE ug.user.name = " # noqa: S608 - f"{Config.config.datagateway_api.use_reader_for_performance.reader_username!r}" + f"{Config.config.icat.reader.username!r}" ), ) From 53a7daa7c252a77da0b3e8c67f8b129f1e796cea Mon Sep 17 00:00:00 2001 From: Patrick Austin Date: Fri, 7 Aug 2026 17:01:07 +0000 Subject: [PATCH 2/2] Implement models and read only endpoints for Investigations, Datasets, Datafiles --- .flake8 | 1 + datagateway_api/common/config.py | 4 +- .../datagateway_api/icat/helpers.py | 27 +- .../datagateway_api/icat/python_icat.py | 6 +- datagateway_api/main.py | 2 +- .../read_only_api/models/__init__.py | 19 + .../read_only_api/models/request/__init__.py | 5 + .../read_only_api/models/request/common.py | 170 +++++++++ .../read_only_api/models/request/datafile.py | 42 +++ .../read_only_api/models/request/dataset.py | 52 +++ .../models/request/investigation.py | 108 ++++++ .../read_only_api/models/response/__init__.py | 5 + .../read_only_api/models/response/common.py | 5 + .../read_only_api/models/response/datafile.py | 14 + .../read_only_api/models/response/dataset.py | 24 ++ .../models/{ => response}/investigation.py | 64 ++-- .../read_only_api/routers/entities.py | 199 ++++++++++ .../read_only_api/routers/my_data.py | 36 -- docker-compose.yml | 4 + test/integration/conftest.py | 5 + .../datagateway_api/icat/conftest.py | 5 - .../models/request/test_common.py | 19 + .../read_only_api/routers/test_entities.py | 357 ++++++++++++++++++ 23 files changed, 1085 insertions(+), 88 deletions(-) create mode 100644 datagateway_api/read_only_api/models/__init__.py create mode 100644 datagateway_api/read_only_api/models/request/__init__.py create mode 100644 datagateway_api/read_only_api/models/request/common.py create mode 100644 datagateway_api/read_only_api/models/request/datafile.py create mode 100644 datagateway_api/read_only_api/models/request/dataset.py create mode 100644 datagateway_api/read_only_api/models/request/investigation.py create mode 100644 datagateway_api/read_only_api/models/response/__init__.py create mode 100644 datagateway_api/read_only_api/models/response/common.py create mode 100644 datagateway_api/read_only_api/models/response/datafile.py create mode 100644 datagateway_api/read_only_api/models/response/dataset.py rename datagateway_api/read_only_api/models/{ => response}/investigation.py (61%) create mode 100644 datagateway_api/read_only_api/routers/entities.py delete mode 100644 datagateway_api/read_only_api/routers/my_data.py create mode 100644 test/integration/read_only_api/models/request/test_common.py create mode 100644 test/integration/read_only_api/routers/test_entities.py diff --git a/.flake8 b/.flake8 index 8ecee450..4f7a2d81 100644 --- a/.flake8 +++ b/.flake8 @@ -13,4 +13,5 @@ per-file-ignores = datagateway_api/datagateway_api/icat/models.py: N815,A003 datagateway_api/datagateway_api/icat/filters.py: C901 datagateway_api/search_api/models.py: B950 + datagateway_api/read_only_api/models/*: N815 enable-extensions=G diff --git a/datagateway_api/common/config.py b/datagateway_api/common/config.py index 1fffe8fb..100f8800 100644 --- a/datagateway_api/common/config.py +++ b/datagateway_api/common/config.py @@ -90,6 +90,8 @@ def _validate(self) -> Self: if self.default > self.maximum: raise ValueError("default limit cannot exceed maximum limit") + return self + class ReadOnlyAPI(DataGatewayAPI): limit: LimitConfig = LimitConfig() @@ -147,7 +149,7 @@ class APIConfig(BaseModel): icat: IcatConfig datagateway_api: Optional[DataGatewayAPI] = None - read_only_api: DataGatewayAPI | None = None + read_only_api: ReadOnlyAPI | None = None reload: Optional[StrictBool] = None host: Optional[StrictStr] = None port: Optional[StrictInt] = None diff --git a/datagateway_api/datagateway_api/icat/helpers.py b/datagateway_api/datagateway_api/icat/helpers.py index fc8ffa9c..aec9941a 100644 --- a/datagateway_api/datagateway_api/icat/helpers.py +++ b/datagateway_api/datagateway_api/icat/helpers.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from functools import wraps import logging +from typing import Literal from cachetools import cached from dateutil.tz import tzlocal @@ -58,9 +59,13 @@ def requires_session_id(method): def wrapper_requires_session(*args, **kwargs): try: client_pool = kwargs.get("client_pool") + if "session_id" in kwargs: + session_id = kwargs["session_id"] + else: + session_id = args[1] - client = get_cached_client(args[1], client_pool) - client.sessionId = args[1] + client = get_cached_client(session_id=session_id, client_pool=client_pool) + client.sessionId = session_id # Client object put into kwargs so it can be accessed by # python ICAT functions kwargs["client"] = client @@ -211,7 +216,7 @@ def get_entity_by_id( entity_type, id_, return_json_formattable_data, - return_related_entities=False, + includes: Literal["1"] | list[str] | None = None, ): """ Gets a record of a given ID from the specified entity @@ -227,26 +232,24 @@ def get_entity_by_id( data will be used as a response for an API call) or whether to leave the data in a Python ICAT format :type return_json_formattable_data: :class:`bool` - :param return_related_entities: Flag to determine whether related entities should - automatically be returned or not. Returning related entities used as a bug fix - for an `IcatException` where ICAT attempts to set a field to null because said - field hasn't been included in the updated data + :param includes: List of include strings, "1" (shorthand for include 1:1 relationships) or None. + Returning related entities used as a bug fix for an `IcatException` where ICAT attempts to set a field to null + because said field hasn't been included in the updated data :type return_related_entities: :class:`bool` :return: The record of the specified ID from the given entity :raises: MissingRecordError: If Python ICAT cannot find a record of the specified ID """ log.info("Getting %s of the ID %s", entity_type, id_) - log.debug("Return related entities set to: %s", return_related_entities) + log.debug("includes set to: %s", includes) # Set query condition for the selected ID id_condition = PythonICATWhereFilter.create_condition("id", "=", id_) - includes_value = "1" if return_related_entities else None id_query = ICATQuery( client, entity_type, conditions=id_condition, - includes=includes_value, + includes=includes, ) entity_by_id_data = id_query.execute_query(client, return_json_formattable_data) @@ -294,7 +297,7 @@ def update_entity_by_id(client, entity_type, id_, new_data): entity_type, id_, False, - return_related_entities=True, + includes="1", ) # There will only ever be one record associated with a single ID - if a record with # the specified ID cannot be found, it'll be picked up by the MissingRecordError in @@ -488,7 +491,7 @@ def update_entities(client, entity_type, data_to_update): entity_type, entity_request["id"], False, - return_related_entities=True, + includes="1", ) icat_data_backup.append(entity_data.copy()) diff --git a/datagateway_api/datagateway_api/icat/python_icat.py b/datagateway_api/datagateway_api/icat/python_icat.py index fe2c4cbc..45c5e113 100644 --- a/datagateway_api/datagateway_api/icat/python_icat.py +++ b/datagateway_api/datagateway_api/icat/python_icat.py @@ -1,4 +1,5 @@ import logging +from typing import Literal from icat.exception import ICATError, ICATSessionError @@ -175,15 +176,16 @@ def count_with_filters(self, session_id, entity_type, filters, **kwargs): @requires_session_id @queries_records - def get_with_id(self, session_id, entity_type, id_, **kwargs): + def get_with_id(self, session_id, entity_type, id_, includes: Literal["1"] | list[str] | None = None, **kwargs): """ Gets the entity matching the given ID for the given entity type. :param session_id: The session ID of the requesting user. :param entity_type: The type of entity. :param id_: The ID of the record to find. + :param includes: List of include strings, "1" (shorthand for include 1:1 relationships) or None. :return: The entity retrieved. """ - return get_entity_by_id(kwargs.get("client"), entity_type, id_, True) + return get_entity_by_id(kwargs.get("client"), entity_type, id_, True, includes=includes) @requires_session_id @queries_records diff --git a/datagateway_api/main.py b/datagateway_api/main.py index 46504057..adbc04f9 100644 --- a/datagateway_api/main.py +++ b/datagateway_api/main.py @@ -9,7 +9,7 @@ from datagateway_api.common.config import Config from datagateway_api.common.exceptions import ApiError from datagateway_api.common.logger_setup import LOGGING_CONFIG_FILE_PATH, setup_logger -from datagateway_api.read_only_api.routers.my_data import my_data_endpoints +from datagateway_api.read_only_api.routers.entities import my_data_endpoints # Check which APIs are enabled datagateway_api_enabled = Config.config.datagateway_api is not None diff --git a/datagateway_api/read_only_api/models/__init__.py b/datagateway_api/read_only_api/models/__init__.py new file mode 100644 index 00000000..d2264a9a --- /dev/null +++ b/datagateway_api/read_only_api/models/__init__.py @@ -0,0 +1,19 @@ +from datagateway_api.read_only_api.models.request import ( + DatafileFilters, + DatasetFilters, + DatasetIncludeEnum, + InvestigationFilters, + InvestigationIncludeEnum, +) +from datagateway_api.read_only_api.models.response import Datafile, Dataset, Investigation + +__all__ = [ + DatafileFilters, + DatasetFilters, + DatasetIncludeEnum, + InvestigationFilters, + InvestigationIncludeEnum, + Datafile, + Dataset, + Investigation, +] diff --git a/datagateway_api/read_only_api/models/request/__init__.py b/datagateway_api/read_only_api/models/request/__init__.py new file mode 100644 index 00000000..d4b352d2 --- /dev/null +++ b/datagateway_api/read_only_api/models/request/__init__.py @@ -0,0 +1,5 @@ +from datagateway_api.read_only_api.models.request.datafile import DatafileFilters +from datagateway_api.read_only_api.models.request.dataset import DatasetFilters, DatasetIncludeEnum +from datagateway_api.read_only_api.models.request.investigation import InvestigationFilters, InvestigationIncludeEnum + +__all__ = [DatafileFilters, DatasetFilters, DatasetIncludeEnum, InvestigationFilters, InvestigationIncludeEnum] diff --git a/datagateway_api/read_only_api/models/request/common.py b/datagateway_api/read_only_api/models/request/common.py new file mode 100644 index 00000000..f300eed6 --- /dev/null +++ b/datagateway_api/read_only_api/models/request/common.py @@ -0,0 +1,170 @@ +from enum import StrEnum +import json +from typing import Any + +from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt, model_serializer, model_validator + +from datagateway_api.common.config import Config +from datagateway_api.datagateway_api.icat.filters import ( + PythonICATIncludeFilter, + PythonICATLimitFilter, + PythonICATOrderFilter, + PythonICATSkipFilter, + PythonICATWhereFilter, +) + +WHERE_DESCRIPTION = ( + "Apply conditions to specified fields.\n\nQueryable fields are: {queryable_fields}.\n\nPossible operators are: " + "'eq' (equals), 'neq'/'ne' (not equals), 'isnull', 'like' (includes), 'ilike' (case-insensitive includes), " + "'nlike' (does not include), 'lt' (less than), 'lte' (less than or equals), 'gt' (greater than), " + "'gte' (greater than or equals), 'in'/'inq', 'nin' (not in), 'between', 'regexp' (regular expression pattern).\n\n" + "The format of a condition should be {{field: {{operator: value}}}}." +) +ORDER_DESCRIPTION = ( + "Order results by the value of the specified field(s) in ascending or descending order.\n\n" + "Orderable fields are: {orderable_fields}.\n\nThe format of an order should be 'field asc' or 'field desc'." +) +INCLUDE_DESCRIPTION = "Include related entities.\n\nPossible includes are: {includable_paths}" + + +class EqualFilter(BaseModel): + eq: str + + +class NotEqualFilter(BaseModel): + ne: str = Field(alias="neq") + + +class IsNullFilter(BaseModel): + isnull: bool + + +class LikeFilter(BaseModel): + like: str + + +class InsensitiveLikeFilter(BaseModel): + ilike: str + + +class NotLikeFilter(BaseModel): + nlike: str + + +class NotInsensitiveLikeFilter(BaseModel): + nilike: str + + +class LessThanFilter(BaseModel): + lt: str + + +class LessThanOrEqualToFilter(BaseModel): + lte: str + + +class GreaterThanFilter(BaseModel): + gt: str + + +class GreaterThanOrEqualToFilter(BaseModel): + gte: str + + +class InFilter(BaseModel): + inq: list = Field(alias="in") + + +class NotInFilter(BaseModel): + nin: list + + +class BetweenFilter(BaseModel): + between: list = Field(min_length=2, max_length=2) + + +class RegexFilter(BaseModel): + regexp: str + + +AnyFilter = ( + EqualFilter + | NotEqualFilter + | IsNullFilter + | LikeFilter + | InsensitiveLikeFilter + | NotLikeFilter + | NotInsensitiveLikeFilter + | LessThanFilter + | LessThanOrEqualToFilter + | GreaterThanFilter + | GreaterThanOrEqualToFilter + | InFilter + | NotInFilter + | BetweenFilter + | RegexFilter + | None +) + + +class CommonWhereFilter(BaseModel): + name: AnyFilter = None + + @model_validator(mode="before") + @classmethod + def validate(cls, data: Any) -> Any: + if isinstance(data, str): + return json.loads(data) + + return data + + +def validate_order(order: list[StrEnum]) -> list[StrEnum]: + unique_keys = set() + for o in order: + key, _ = o.split() + if key in unique_keys: + raise ValueError("Cannot order on the same field multiple times") + unique_keys.add(key) + + return order + + +class CommonFilters(BaseModel): + # where: list[BaseModel] + where: list[CommonWhereFilter] + order: list[StrEnum] + skip: NonNegativeInt = Field( + default=0, + description="Skip the first results returned by the query. Used for pagination.", + ) + limit: PositiveInt = Field( + default=Config.config.read_only_api.limit.default if Config.config.read_only_api is not None else 100, + le=Config.config.read_only_api.limit.maximum if Config.config.read_only_api is not None else 100, + description="Return at most this many results per request.", + ) + + @model_serializer(mode="plain") + def serialize(self) -> list: + filters = [PythonICATSkipFilter(skip_value=self.skip), PythonICATLimitFilter(limit_value=self.limit)] + for where_filter in self.where: + for field, inner in where_filter.model_dump(by_alias=True, exclude_none=True).items(): + for operation, value in inner.items(): + filters.append(PythonICATWhereFilter(field=field, operation=operation, value=value)) + + for order_filter in self.order: + filters.append(PythonICATOrderFilter(*order_filter.split())) + + return filters + + +class CommonAndIncludeFilters(CommonFilters): + include: list[StrEnum] + + @model_serializer(mode="plain") + def serialize(self) -> list: + filters = super().serialize() + if self.include: + filters.append(PythonICATIncludeFilter([i.value for i in self.include])) + + return filters diff --git a/datagateway_api/read_only_api/models/request/datafile.py b/datagateway_api/read_only_api/models/request/datafile.py new file mode 100644 index 00000000..4c6d3ffe --- /dev/null +++ b/datagateway_api/read_only_api/models/request/datafile.py @@ -0,0 +1,42 @@ +from enum import StrEnum +from typing import Annotated + +from pydantic import AfterValidator, Field + +from datagateway_api.read_only_api.models.request.common import ( + ORDER_DESCRIPTION, + WHERE_DESCRIPTION, + AnyFilter, + CommonFilters, + CommonWhereFilter, + validate_order, +) + + +class DatafileOrderEnum(StrEnum): + NAME_ASC = "name asc" + NAME_DESC = "name desc" + LOCATION_ASC = "location asc" + LOCATION_DESC = "location desc" + FILE_SIZE_ASC = "fileSize asc" + FILE_SIZE_DESC = "fileSize desc" + DATAFILE_CREATE_TIME_ASC = "datafileCreateTime asc" + DATAFILE_CREATE_TIME_DESC = "datafileCreateTime desc" + + +class DatafileWhereFilter(CommonWhereFilter): + location: AnyFilter = None + datafileCreateTime: AnyFilter = None + + +class DatafileFilters(CommonFilters): + where: list[DatafileWhereFilter] = Field( + default=[], + description=WHERE_DESCRIPTION.format(queryable_fields="'name', 'location', and 'datafileCreateTime'"), + ) + order: Annotated[list[DatafileOrderEnum], AfterValidator(validate_order)] = Field( + default=[], + description=ORDER_DESCRIPTION.format( + orderable_fields="'name', 'location', 'fileSize', and 'datafileCreateTime'", + ), + ) diff --git a/datagateway_api/read_only_api/models/request/dataset.py b/datagateway_api/read_only_api/models/request/dataset.py new file mode 100644 index 00000000..343327b2 --- /dev/null +++ b/datagateway_api/read_only_api/models/request/dataset.py @@ -0,0 +1,52 @@ +from enum import StrEnum +from typing import Annotated + +from pydantic import AfterValidator, Field + +from datagateway_api.read_only_api.models.request.common import ( + INCLUDE_DESCRIPTION, + ORDER_DESCRIPTION, + WHERE_DESCRIPTION, + AnyFilter, + CommonAndIncludeFilters, + CommonWhereFilter, + validate_order, +) + +DATASET_INCLUDE_DESCRIPTION = INCLUDE_DESCRIPTION.format(includable_paths="'type'") + + +class DatasetOrderEnum(StrEnum): + NAME_ASC = "name asc" + NAME_DESC = "name desc" + FILE_COUNT_ASC = "fileCount asc" + FILE_COUNT_DESC = "fileCount desc" + FILE_SIZE_ASC = "fileSize asc" + FILE_SIZE_DESC = "fileSize desc" + CREATE_TIME_ASC = "createTime asc" + CREATE_TIME_DESC = "createTime desc" + MOD_TIME_ASC = "modTime asc" + MOD_TIME_DESC = "modTime desc" + + +class DatasetWhereFilter(CommonWhereFilter): + createTime: AnyFilter = None + modTime: AnyFilter = None + + +class DatasetIncludeEnum(StrEnum): + TYPE = "type" + + +class DatasetFilters(CommonAndIncludeFilters): + where: list[DatasetWhereFilter] = Field( + default=[], + description=WHERE_DESCRIPTION.format(queryable_fields="'name', 'createTime', and 'modTime'"), + ) + order: Annotated[list[DatasetOrderEnum], AfterValidator(validate_order)] = Field( + default=[], + description=ORDER_DESCRIPTION.format( + orderable_fields="'name', 'fileCount', 'fileSize', 'createTime', and 'modTime'", + ), + ) + include: list[DatasetIncludeEnum] = Field(default=[], description=DATASET_INCLUDE_DESCRIPTION) diff --git a/datagateway_api/read_only_api/models/request/investigation.py b/datagateway_api/read_only_api/models/request/investigation.py new file mode 100644 index 00000000..17ff7e7b --- /dev/null +++ b/datagateway_api/read_only_api/models/request/investigation.py @@ -0,0 +1,108 @@ +from enum import StrEnum +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, Field, model_serializer + +from datagateway_api.datagateway_api.icat.filters import PythonICATDistinctFieldFilter +from datagateway_api.read_only_api.models.request.common import ( + INCLUDE_DESCRIPTION, + ORDER_DESCRIPTION, + WHERE_DESCRIPTION, + AnyFilter, + CommonAndIncludeFilters, + CommonWhereFilter, + validate_order, +) + +INVESTIGATION_INCLUDE_DESCRIPTION = INCLUDE_DESCRIPTION.format( + includable_paths=( + "'investigationInstruments.instrument', 'investigationUsers.user', 'samples.type', 'parameters.type', " + "and 'publications'" + ), +) + + +class InvestigationDistinctEnum(StrEnum): + TITLE = "title" + NAME = "name" + + +class InvestigationOrderEnum(StrEnum): + TITLE_ASC = "title asc" + TITLE_DESC = "title desc" + NAME_ASC = "name asc" + NAME_DESC = "name desc" + VISIT_ID_ASC = "visitId asc" + VISIT_ID_DESC = "visitId desc" + FILE_SIZE_ASC = "fileSize asc" + FILE_SIZE_DESC = "fileSize desc" + START_DATE_ASC = "startDate asc" + START_DATE_DESC = "startDate desc" + END_DATE_ASC = "endDate asc" + END_DATE_DESC = "endDate desc" + + +class TitleFilter(BaseModel): + title: AnyFilter + + +class VisitIdFilter(BaseModel): + visitId: AnyFilter + + +class StartDateFilter(BaseModel): + startDate: AnyFilter + + +class EndDateFilter(BaseModel): + endDate: AnyFilter + + +class InstrumentNameFilter(BaseModel): + instrumentName: AnyFilter = Field(alias="investigationInstruments.instrument.name") + + +class InvestigationWhereFilter(CommonWhereFilter): + title: AnyFilter = None + visitId: AnyFilter = None + startDate: AnyFilter = None + endDate: AnyFilter = None + instrumentName: AnyFilter = Field(default=None, alias="investigationInstruments.instrument.name") + + +class InvestigationIncludeEnum(StrEnum): + INSTRUMENTS = "investigationInstruments.instrument" + USERS = "investigationUsers.user" + SAMPLES = "samples.type" + PARAMETERS = "parameters.type" + PUBLICATIONS = "publications" + + +class InvestigationFilters(CommonAndIncludeFilters): + distinct: list[InvestigationDistinctEnum] = Field( + default=[], + description="Return distinct value(s) of the specified fields. Only these fields will be returned.", + ) + where: list[InvestigationWhereFilter] = Field( + default=[], + description=WHERE_DESCRIPTION.format( + queryable_fields=( + "'name', 'title', 'visitId', 'startDate', 'endDate', and 'investigationInstruments.instrument.name'", + ), + ), + ) + order: Annotated[list[InvestigationOrderEnum], AfterValidator(validate_order)] = Field( + default=[], + description=ORDER_DESCRIPTION.format( + orderable_fields="'name', 'title', 'visitId', 'fileSize', 'startDate', and 'endDate'", + ), + ) + include: list[InvestigationIncludeEnum] = Field(default=[], description=INVESTIGATION_INCLUDE_DESCRIPTION) + + @model_serializer(mode="plain") + def serialize(self) -> list: + filters = super().serialize() + if self.distinct: + filters.insert(0, PythonICATDistinctFieldFilter([d.value for d in self.distinct])) + + return filters diff --git a/datagateway_api/read_only_api/models/response/__init__.py b/datagateway_api/read_only_api/models/response/__init__.py new file mode 100644 index 00000000..be4845d3 --- /dev/null +++ b/datagateway_api/read_only_api/models/response/__init__.py @@ -0,0 +1,5 @@ +from datagateway_api.read_only_api.models.response.datafile import Datafile +from datagateway_api.read_only_api.models.response.dataset import Dataset +from datagateway_api.read_only_api.models.response.investigation import Investigation + +__all__ = [Datafile, Dataset, Investigation] diff --git a/datagateway_api/read_only_api/models/response/common.py b/datagateway_api/read_only_api/models/response/common.py new file mode 100644 index 00000000..76a5e087 --- /dev/null +++ b/datagateway_api/read_only_api/models/response/common.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class EntityModel(BaseModel): + id_: int | None = Field(default=None, alias="id") diff --git a/datagateway_api/read_only_api/models/response/datafile.py b/datagateway_api/read_only_api/models/response/datafile.py new file mode 100644 index 00000000..8a6d8d57 --- /dev/null +++ b/datagateway_api/read_only_api/models/response/datafile.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from datagateway_api.read_only_api.models.response.common import EntityModel + + +class Datafile(EntityModel): + name: str | None = None + location: str | None = None + description: str | None = None + doi: str | None = None + checksum: str | None = None + datafileCreateTime: datetime | None = None + datafileModTime: datetime | None = None + fileSize: int | None = None diff --git a/datagateway_api/read_only_api/models/response/dataset.py b/datagateway_api/read_only_api/models/response/dataset.py new file mode 100644 index 00000000..e3c7cecb --- /dev/null +++ b/datagateway_api/read_only_api/models/response/dataset.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import Field + +from datagateway_api.read_only_api.models.response.common import EntityModel + + +class DatasetType(EntityModel): + name: str | None = None + description: str | None = None + + +class Dataset(EntityModel): + name: str | None = None + location: str | None = None + description: str | None = None + doi: str | None = None + startDate: datetime | None = None + endDate: datetime | None = None + fileCount: int | None = None + fileSize: int | None = None + complete: bool | None = None + + type_: DatasetType | None = Field(default=None, alias="type") diff --git a/datagateway_api/read_only_api/models/investigation.py b/datagateway_api/read_only_api/models/response/investigation.py similarity index 61% rename from datagateway_api/read_only_api/models/investigation.py rename to datagateway_api/read_only_api/models/response/investigation.py index bc1eed7a..07a0a25c 100644 --- a/datagateway_api/read_only_api/models/investigation.py +++ b/datagateway_api/read_only_api/models/response/investigation.py @@ -1,51 +1,53 @@ from datetime import datetime from enum import StrEnum -from pydantic import BaseModel +from pydantic import Field +from datagateway_api.read_only_api.models.response.common import EntityModel -class Instrument(BaseModel): - name: str + +class Instrument(EntityModel): + name: str | None = None fullName: str | None = None description: str | None = None - type: str | None = None + type_: str | None = Field(default=None, alias="type") url: str | None = None pid: str | None = None startDate: datetime | None = None endDate: datetime | None = None -class InvestigationInstrument(BaseModel): - instrument: Instrument +class InvestigationInstrument(EntityModel): + instrument: Instrument | None = None -class User(BaseModel): - name: str +class User(EntityModel): + name: str | None = None fullName: str | None = None givenName: str | None = None familyName: str | None = None email: str | None = None affiliation: str | None = None - orcid: str | None = None + orcidId: str | None = None -class InvestigationUser(BaseModel): - role: str +class InvestigationUser(EntityModel): + role: str | None = None - user: User + user: User | None = None -class SampleType(BaseModel): - name: str - molecularFormula: str +class SampleType(EntityModel): + name: str | None = None + molecularFormula: str | None = None safetyInformation: str | None = None -class Sample(BaseModel): - name: str +class Sample(EntityModel): + name: str | None = None pid: str | None = None - type: SampleType + type_: SampleType | None = Field(default=None, alias="type") class ParameterValueType(StrEnum): @@ -54,10 +56,10 @@ class ParameterValueType(StrEnum): NUMERIC = "NUMERIC" -class ParameterType(BaseModel): - name: str - valueType: ParameterValueType - units: str +class ParameterType(EntityModel): + name: str | None = None + valueType: ParameterValueType | None = None + units: str | None = None unitsFullName: str | None = None pid: str | None = None description: str | None = None @@ -72,7 +74,7 @@ class ParameterType(BaseModel): applicableToDataCollection: bool | None = None -class Parameter(BaseModel): +class Parameter(EntityModel): stringValue: str | None = None dateTimeValue: datetime | None = None numericValue: float | None = None @@ -80,21 +82,21 @@ class Parameter(BaseModel): rangeBottom: float | None = None rangeTop: float | None = None - type: ParameterType + type_: ParameterType | None = Field(default=None, alias="type") -class Publication(BaseModel): - fullReference: str +class Publication(EntityModel): + fullReference: str | None = None doi: str | None = None url: str | None = None repository: str | None = None repositoryId: str | None = None -class Investigation(BaseModel): - name: str - visitId: str - title: str +class Investigation(EntityModel): + name: str | None = None + visitId: str | None = None + title: str | None = None summary: str | None = None doi: str | None = None startDate: datetime | None = None @@ -103,7 +105,7 @@ class Investigation(BaseModel): fileCount: int | None = None fileSize: int | None = None - investigationInstruments: list[InvestigationInstrument] + investigationInstruments: list[InvestigationInstrument] | None = None investigationUsers: list[InvestigationUser] | None = None samples: list[Sample] | None = None parameters: list[Parameter] | None = None diff --git a/datagateway_api/read_only_api/routers/entities.py b/datagateway_api/read_only_api/routers/entities.py new file mode 100644 index 00000000..020b91a5 --- /dev/null +++ b/datagateway_api/read_only_api/routers/entities.py @@ -0,0 +1,199 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from fastapi.security import HTTPAuthorizationCredentials +from object_pool import ObjectPool +from pydantic import PositiveInt + +from datagateway_api.auth.session_bearer import SessionBearer +from datagateway_api.datagateway_api.icat.filters import PythonICATWhereFilter +from datagateway_api.datagateway_api.icat.python_icat import PythonICAT +from datagateway_api.read_only_api.models import ( + DatafileFilters, + DatasetFilters, + DatasetIncludeEnum, + InvestigationFilters, + InvestigationIncludeEnum, + Datafile, + Dataset, + Investigation, +) +from datagateway_api.read_only_api.models.request.dataset import DATASET_INCLUDE_DESCRIPTION +from datagateway_api.read_only_api.models.request.investigation import INVESTIGATION_INCLUDE_DESCRIPTION + +SessionId = Annotated[HTTPAuthorizationCredentials, Depends(SessionBearer())] + + +def my_data_endpoints(python_icat: PythonICAT, client_pool: ObjectPool) -> APIRouter: + router = APIRouter(tags=["Entities"]) + + @router.get( + "/investigations", + summary="Get Investigations", + description="Get Investigations and related Entities based on the provided filters.", + response_model=list[Investigation], + response_model_exclude_none=True, + responses={ + 200: {"description": "Success - returns Investigations that satisfy the filters"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_investigations( + session_id: SessionId, + investigation_filters: Annotated[InvestigationFilters, Query()], + ) -> list[Investigation]: + return python_icat.get_with_filters( + session_id=session_id, + entity_type="Investigation", + filters=investigation_filters.model_dump(), + client_pool=client_pool, + ) + + @router.get( + "/investigations/{investigation_id}", + summary="Get a single Investigation", + description="Get a single Investigation and related Entities based on the provided id", + response_model=Investigation, + response_model_exclude_none=True, + responses={ + 200: {"description": "Success - returns the requested Investigation"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_investigation( + session_id: SessionId, + investigation_id: PositiveInt, + include: Annotated[ + list[InvestigationIncludeEnum], + Query(description=INVESTIGATION_INCLUDE_DESCRIPTION), + ] = [], # noqa: B006 + ) -> Investigation: + return python_icat.get_with_id( + session_id=session_id, + entity_type="Investigation", + id_=investigation_id, + includes=[i.value for i in include], + client_pool=client_pool, + ) + + @router.get( + "/investigations/{investigation_id}/datasets", + summary="Get Datasets", + description="Get Datasets and related Entities based on the provided filters.", + response_model=list[Dataset], + response_model_exclude_none=True, + responses={ + 200: {"description": "Success - returns Datasets that satisfy the filters"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_datasets( + session_id: SessionId, + investigation_id: PositiveInt, + dataset_filters: Annotated[DatasetFilters, Query()], + ) -> list[Dataset]: + return python_icat.get_with_filters( + session_id=session_id, + entity_type="Dataset", + filters=[ + PythonICATWhereFilter(field="investigation.id", operation="eq", value=investigation_id), + *dataset_filters.model_dump(), + ], + client_pool=client_pool, + ) + + @router.get( + "/investigations/{investigation_id}/datasets/{dataset_id}", + summary="Get a single Dataset", + description="Get a single Dataset and related Entities based on the provided id.", + response_model=Dataset, + response_model_exclude_none=True, + responses={ + 200: {"description": "Success - returns the requested Dataset"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_dataset( + session_id: SessionId, + investigation_id: PositiveInt, + dataset_id: PositiveInt, + include: Annotated[list[DatasetIncludeEnum], Query(description=DATASET_INCLUDE_DESCRIPTION)] = [], # noqa: B006 + ) -> Dataset: + return python_icat.get_with_id( + session_id=session_id, + entity_type="Dataset", + id_=dataset_id, + includes=[i.value for i in include], + client_pool=client_pool, + ) + + @router.get( + "/investigations/{investigation_id}/datasets/{dataset_id}/datafiles", + summary="Get Datafiles", + description="Get Datafiles based on the provided filters.", + response_model=list[Datafile], + response_model_exclude_none=True, + responses={ + 200: {"description": "Success - returns Datafiles that satisfy the filters"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_datafiles( + session_id: SessionId, + investigation_id: PositiveInt, + dataset_id: PositiveInt, + datafile_filters: Annotated[DatafileFilters, Query()], + ) -> list[Datafile]: + return python_icat.get_with_filters( + session_id=session_id, + entity_type="Datafile", + filters=[ + PythonICATWhereFilter(field="dataset.id", operation="eq", value=dataset_id), + *datafile_filters.model_dump(), + ], + client_pool=client_pool, + ) + + @router.get( + "/investigations/{investigation_id}/datasets/{dataset_id}/datafiles/{datafile_id}", + summary="Get a single Datafile", + description="Get a single Datafile based on the provided id.", + response_model=Datafile, + response_model_exclude_none=True, + responses={ + 200: {"description": "Success - returns requested Datafile"}, + 400: {"description": "Bad request - Something was wrong with the request"}, + 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, + 403: {"description": "Forbidden - The session ID provided is invalid"}, + 404: {"description": "No such record - Unable to find a record in ICAT"}, + }, + ) + def get_datafile( + investigation_id: PositiveInt, + dataset_id: PositiveInt, + datafile_id: PositiveInt, + session_id: SessionId, + ) -> Datafile: + return python_icat.get_with_id( + session_id=session_id, + entity_type="Datafile", + id_=datafile_id, + client_pool=client_pool, + ) + + return router diff --git a/datagateway_api/read_only_api/routers/my_data.py b/datagateway_api/read_only_api/routers/my_data.py deleted file mode 100644 index f11ee4ed..00000000 --- a/datagateway_api/read_only_api/routers/my_data.py +++ /dev/null @@ -1,36 +0,0 @@ -from fastapi import APIRouter, Depends -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from object_pool import ObjectPool - -from datagateway_api.datagateway_api.icat.python_icat import PythonICAT -from datagateway_api.read_only_api.models.investigation import Investigation - - -security = HTTPBearer() - - -def my_data_endpoints(python_icat: PythonICAT, icat_client_pool: ObjectPool) -> APIRouter: - router = APIRouter(prefix="/my_data", tags="My data") - - @router.get( - "/investigations", - summary="Get investigations", - description="Get investigations", - response_model=list[Investigation], - responses={ - 200: {"description": f"Success - returns Investigations that satisfy the filters"}, - 400: {"description": "Bad request - Something was wrong with the request"}, - 401: {"description": "Unauthorized - No session ID found in HTTP Auth. header"}, - 403: {"description": "Forbidden - The session ID provided is invalid"}, - 404: {"description": "No such record - Unable to find a record in ICAT"}, - }, - ) - def get_investigations(credentials: HTTPAuthorizationCredentials = Depends(security)): - return python_icat.get_with_filters( - session_id=credentials.credentials, - entity_type="Investigaton", - filters=[], - icat_client_pool=icat_client_pool, - ) - - return router diff --git a/docker-compose.yml b/docker-compose.yml index 469c1125..b3160a53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: depends_on: icat_mariadb: condition: service_healthy + auth_anon: + condition: service_started + auth_simple: + condition: service_started ports: - "14747:4848" # payara port - "18181:8181" # https port diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 59857ba2..032ea3aa 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -26,6 +26,11 @@ def icat_client(): return client +@pytest.fixture(scope="package") +def valid_icat_credentials_header(icat_client: Client) -> dict[str, str]: + return {"Authorization": f"Bearer {icat_client.sessionId}"} + + @pytest.fixture(name="test_client") def fixture_test_client() -> TestClient: """ diff --git a/test/integration/datagateway_api/icat/conftest.py b/test/integration/datagateway_api/icat/conftest.py index 2211b46a..11b5866f 100644 --- a/test/integration/datagateway_api/icat/conftest.py +++ b/test/integration/datagateway_api/icat/conftest.py @@ -14,11 +14,6 @@ ) -@pytest.fixture() -def valid_icat_credentials_header(icat_client): - return {"Authorization": f"Bearer {icat_client.sessionId}"} - - def create_investigation_test_data(client, num_entities=1): test_data = [] diff --git a/test/integration/read_only_api/models/request/test_common.py b/test/integration/read_only_api/models/request/test_common.py new file mode 100644 index 00000000..618bc3bd --- /dev/null +++ b/test/integration/read_only_api/models/request/test_common.py @@ -0,0 +1,19 @@ +import json + +import pytest + +from datagateway_api.read_only_api.models.request.common import CommonWhereFilter, validate_order +from datagateway_api.read_only_api.models.request.investigation import InvestigationOrderEnum + + +class TestCommon: + @pytest.mark.parametrize( + ["obj"], + [pytest.param({"name": {"eq": "name"}}), pytest.param(json.dumps({"name": {"eq": "name"}}))], + ) + def test_common_where_filter(self, obj: dict[str, dict[str, str]] | str) -> None: + CommonWhereFilter.model_validate(obj) + + def test_validate_order(self) -> None: + with pytest.raises(ValueError, match="Cannot order on the same field multiple times"): + validate_order([InvestigationOrderEnum.TITLE_ASC, InvestigationOrderEnum.TITLE_DESC]) diff --git a/test/integration/read_only_api/routers/test_entities.py b/test/integration/read_only_api/routers/test_entities.py new file mode 100644 index 00000000..e1f0afa7 --- /dev/null +++ b/test/integration/read_only_api/routers/test_entities.py @@ -0,0 +1,357 @@ +import json +from unittest.mock import ANY + +from fastapi.testclient import TestClient +import pytest + +INVESTIGATION_1 = { + "doi": "0-417-77631-4", + "endDate": "2000-07-09T00:00:00Z", + "fileCount": 0, + "fileSize": 0, + "id": 1, + "name": "INVESTIGATION 1", + "releaseDate": "2000-07-05T00:00:00Z", + "startDate": "2000-04-03T00:00:00Z", + "summary": ( + "Throw hope parent. Receive entire soon. War top air agent must voice high describe.\n" + "Month shake voice. Do discuss despite least face again study. Two beyond picture rich fast sea time." + ), + "title": "Analysis reflect work or hour color maybe.\nMuch team discussion message weight.", + "visitId": "70", +} +INVESTIGATION_1_INVESTIGATION_INSTRUMENTS = { + "investigationInstruments": [ + { + "id": 1, + "instrument": { + "id": 3, + "description": ( + "Financial vote season indicate. Candidate night sure opportunity design.\n" + "Commercial test wind region meeting her get. Of to option manage visit. " + "Fast matter foot the tonight adult." + ), + "fullName": ( + "Rise be college treat. Environmental forward media effort fund.\n" + "Dog want single resource major. Necessary bit always available term small stock game." + ), + "name": "INSTRUMENT 3", + "type": "3", + "url": "http://www.jackson-allen.com/", + }, + }, + ], +} +INVESTIGATION_1_INVESTIGATION_USERS = { + "investigationUsers": [ + { + "id": 1, + "role": "PI", + "user": { + "id": 292, + "email": "jenniferadams@hotmail.com", + "fullName": "Colleen Heath", + "name": "Jenny292", + "orcidId": "19931", + }, + }, + ], +} +INVESTIGATION_1_PARAMETERS = { + "parameters": [ + { + "id": 1, + "error": 4135.0, + "numericValue": 38.0, + "rangeBottom": 17.0, + "rangeTop": 57.0, + "type": { + "applicableToDataCollection": True, + "applicableToDatafile": True, + "applicableToDataset": True, + "applicableToInvestigation": True, + "applicableToSample": True, + "description": ( + "Tv shake population. City she third find realize support.\n" + "Red say organization task. Whether number computer economy design now serious appear. " + "Response girl middle close role American." + ), + "enforced": True, + "id": 9, + "maximumNumericValue": 60.0, + "minimumNumericValue": 2.0, + "name": "PARAMETERTYPE 9", + "units": "unit 9", + "unitsFullName": "where", + "valueType": "NUMERIC", + "verified": True, + }, + }, + ], +} +INVESTIGATION_1_PUBLICATIONS = { + "publications": [ + { + "id": 59, + "doi": "1-326-70532-6", + "fullReference": ( + "Simple notice since view check over through there. " + "Hotel provide available a air avoid beautiful technology." + ), + "repository": "http://www.dillon.info/app/blog/explore/post.htm", + "repositoryId": "5145083", + "url": "https://www.roberts.org/", + }, + { + "id": 118, + "doi": "1-5142-4022-X", + "fullReference": ( + "Him by easy color factor per campaign training. Herself direction big reach high ahead happen hit.\n" + "Seek describe letter detail Congress either different unit. Buy community doctor." + ), + "repository": "https://www.phillips-jones.com/posts/index.php", + "repositoryId": "4437825", + "url": "http://www.jackson.com/", + }, + { + "id": 177, + "doi": "0-920183-44-1", + "fullReference": ( + "Occur how teach. Last fine organization single.\nThose vote possible boy. West thus top right." + ), + "repository": "http://ware-peterson.com/category/faq.php", + "repositoryId": "13298920", + "url": "http://swanson.com/", + }, + ], +} +INVESTIGATION_1_SAMPLES = { + "samples": [ + { + "id": 1, + "name": "SAMPLE 1", + "type": { + "id": 18, + "molecularFormula": "13133", + "name": "SAMPLETYPE 18", + "safetyInformation": ( + "Individual five evening see minute across. Chance trial for foreign. Later evidence law hair.\n" + "Two soon care model. Table edge early off full wrong someone. I let woman mother cold chance." + ), + }, + }, + ], +} +DATASET_1 = { + "complete": True, + "description": ( + "Suggest shake effort many last prepare small. Maintain throw hope parent.\n" + "Entire soon option bill fish against power.\nRather why rise month shake voice." + ), + "doi": "0-449-78690-0", + "endDate": "2000-07-05T00:00:00Z", + "fileCount": 15, + "fileSize": 0, + "id": 1, + "location": "/international/subject.tiff", + "name": "DATASET 1", + "startDate": "2000-05-07T00:00:00Z", +} +DATASET_1_TYPE = { + "type": { + "description": ( + "Stop prove field onto think suffer measure. Table lose season identify professor happen third simply. " + "Beat professional blue clear style have.\nAnalysis reflect work or hour color maybe." + ), + "id": 2, + "name": "DATASETTYPE 2", + }, +} +DATAFILE_1190 = { + "checksum": "fb4255d735510dbfeca7654ac2f6dff9", + "datafileCreateTime": ANY, + "datafileModTime": ANY, + "description": "Company mother month service message this. Site structure state it itself.", + "doi": "0-85288-758-2", + "fileSize": 155061161, + "id": 1190, + "location": "/too/lawyer/camera.jpg", + "name": "Datafile 1190", +} + + +class TestMyData: + @pytest.mark.parametrize( + ["params", "body"], + [ + pytest.param( + { + "where": json.dumps( + { + "name": {"like": "INVESTIGATION"}, + "title": {"ilike": "analysis"}, + "visitId": {"between": ["69", "71"]}, + "startDate": {"gt": "2000-01-01 00:00:00"}, + "endDate": {"lt": "2001-01-01 00:00:00"}, + "investigationInstruments.instrument.name": {"nilike": "1"}, + }, + ), + "order": "name asc", + "include": [ + "investigationInstruments.instrument", + "investigationUsers.user", + "samples.type", + "parameters.type", + "publications", + ], + }, + [ + { + **INVESTIGATION_1, + **INVESTIGATION_1_INVESTIGATION_INSTRUMENTS, + **INVESTIGATION_1_INVESTIGATION_USERS, + **INVESTIGATION_1_PARAMETERS, + **INVESTIGATION_1_PUBLICATIONS, + **INVESTIGATION_1_SAMPLES, + }, + ], + ), + pytest.param( + {"distinct": ["name", "title"], "skip": 10, "limit": 3}, + [ + { + "name": "INVESTIGATION 11", + "title": ( + "Quite world game over million. Business get box.\n" + "American back right billion first especially anyone. Bad understand head.\n" + "Quickly event middle focus. Good sound political successful." + ), + }, + { + "name": "INVESTIGATION 12", + "title": ( + "Dream none group city since trouble finish they. " + "Effect personal together trouble pay increase." + ), + }, + { + "name": "INVESTIGATION 13", + "title": ( + "Again teacher letter. Coach card no step side PM.\n" + "Network recognize recognize space many everything. Else evidence compare return. " + "Room from central effort." + ), + }, + ], + ), + ], + ) + def test_get_investigations( + self, + test_client: TestClient, + valid_icat_credentials_header: dict[str, str], + params: dict, + body: list[dict], + ) -> None: + response = test_client.get( + url="/read-only-api/investigations", + params=params, + headers=valid_icat_credentials_header, + ) + assert response.status_code == 200, response.text + assert response.json() == body + + @pytest.mark.parametrize( + ["includes", "included_body"], + [ + pytest.param([], {}), + pytest.param(["investigationInstruments.instrument"], INVESTIGATION_1_INVESTIGATION_INSTRUMENTS), + pytest.param(["investigationUsers.user"], INVESTIGATION_1_INVESTIGATION_USERS), + pytest.param(["samples.type"], INVESTIGATION_1_SAMPLES), + pytest.param(["parameters.type"], INVESTIGATION_1_PARAMETERS), + pytest.param(["publications"], INVESTIGATION_1_PUBLICATIONS), + ], + ) + def test_get_investigation( + self, + test_client: TestClient, + valid_icat_credentials_header: dict[str, str], + includes: int, + included_body: int, + ) -> None: + response = test_client.get( + url="/read-only-api/investigations/1", + params={"include": includes}, + headers=valid_icat_credentials_header, + ) + assert response.status_code == 200, response.text + assert response.json() == {**INVESTIGATION_1, **included_body} + + @pytest.mark.parametrize( + ["includes", "included_body"], + [pytest.param([], {}), pytest.param(["type"], DATASET_1_TYPE)], + ) + def test_get_datasets( + self, + test_client: TestClient, + valid_icat_credentials_header: dict[str, str], + includes: list[str], + included_body: dict, + ) -> None: + response = test_client.get( + url="/read-only-api/investigations/1/datasets", + params={ + "where": json.dumps( + {"name": {"nlike": "61"}, "createTime": {"isnull": False}, "modTime": {"isnull": False}}, + ), + "include": includes, + }, + headers=valid_icat_credentials_header, + ) + assert response.status_code == 200, response.text + assert response.json() == [{**DATASET_1, **included_body}] + + @pytest.mark.parametrize( + ["includes", "included_body"], + [pytest.param([], {}), pytest.param(["type"], DATASET_1_TYPE)], + ) + def test_get_dataset( + self, + test_client: TestClient, + valid_icat_credentials_header: dict[str, str], + includes: list[str], + included_body: dict, + ) -> None: + response = test_client.get( + url="/read-only-api/investigations/1/datasets/1", + params={"include": includes}, + headers=valid_icat_credentials_header, + ) + assert response.status_code == 200, response.text + assert response.json() == {**DATASET_1, **included_body} + + def test_get_datafiles(self, test_client: TestClient, valid_icat_credentials_header: dict[str, str]) -> None: + response = test_client.get( + url="/read-only-api/investigations/1/datasets/1/datafiles", + params={ + "where": json.dumps( + {"name": {"like": "1"}, "location": {"ilike": "JPG"}, "datafileCreateTime": {"isnull": False}}, + ), + "order": "location asc", + }, + headers=valid_icat_credentials_header, + ) + assert response.status_code == 200, response.text + assert response.json() == [DATAFILE_1190] + + def test_get_datafile( + self, + test_client: TestClient, + valid_icat_credentials_header: dict[str, str], + ) -> None: + response = test_client.get( + url="/read-only-api/investigations/1/datasets/1/datafiles/1190", + headers=valid_icat_credentials_header, + ) + assert response.status_code == 200, response.text + assert response.json() == DATAFILE_1190