diff --git a/changelog.d/20241018_183148_steliosvoutsinas_DM_46975.md b/changelog.d/20241018_183148_steliosvoutsinas_DM_46975.md new file mode 100644 index 0000000..6361511 --- /dev/null +++ b/changelog.d/20241018_183148_steliosvoutsinas_DM_46975.md @@ -0,0 +1,9 @@ + + +### New features + +- Setup self-description when maxrec is set to 0 + +### Other changes + +- Move obscore config loading to a dependency run at startime diff --git a/src/sia/constants.py b/src/sia/constants.py index b211204..8cc18a6 100644 --- a/src/sia/constants.py +++ b/src/sia/constants.py @@ -13,3 +13,6 @@ "responseformat", } """Parameters that should be treated as single values.""" + +BASE_RESOURCE_IDENTIFIER = "ivo://rubin/" +"""The base resource identifier for any rubin SIA service.""" diff --git a/src/sia/dependencies/context.py b/src/sia/dependencies/context.py index c10ca42..ce45697 100644 --- a/src/sia/dependencies/context.py +++ b/src/sia/dependencies/context.py @@ -16,6 +16,7 @@ from ..config import Config from ..factory import Factory from .labeled_butler_factory import labeled_butler_factory_dependency +from .obscore_configs import obscore_config_dependency __all__ = [ "ContextDependency", @@ -96,6 +97,7 @@ async def create_factory(self, logger: BoundLogger) -> Factory: logger=logger, config=self._config, labeled_butler_factory=await labeled_butler_factory_dependency(), + obscore_configs=await obscore_config_dependency(), ) async def aclose(self) -> None: diff --git a/src/sia/dependencies/obscore_configs.py b/src/sia/dependencies/obscore_configs.py new file mode 100644 index 0000000..089de13 --- /dev/null +++ b/src/sia/dependencies/obscore_configs.py @@ -0,0 +1,36 @@ +"""Dependency class for loading the Obscore configs.""" + +from lsst.dax.obscore import ExporterConfig + +from ..config import Config + + +class ObscoreConfigDependency: + """Provides a mapping of label names to Obscore (Exporter) Configs as a + dependency. + """ + + def __init__(self) -> None: + self._config_mapping: dict[str, ExporterConfig] | None = None + + async def initialize(self, config: Config) -> None: + """Initialize the dependency by processing the Butler Collections.""" + self._config_mapping = {} + for collection in config.butler_data_collections: + exporter_config = collection.get_exporter_config() + self._config_mapping[collection.label] = exporter_config + + async def __call__(self) -> dict[str, ExporterConfig]: + """Return the mapping of label names to ExporterConfigs.""" + if self._config_mapping is None: + raise RuntimeError("ExporterConfigDependency is not initialized") + return self._config_mapping + + async def aclose(self) -> None: + """Clear the config mapping.""" + self._config_mapping = None + + +obscore_config_dependency = ObscoreConfigDependency() +"""The dependency that will return the mapping of label names to +Obscore Configs.""" diff --git a/src/sia/factory.py b/src/sia/factory.py index 8806100..39051d7 100644 --- a/src/sia/factory.py +++ b/src/sia/factory.py @@ -5,6 +5,7 @@ import structlog from lsst.daf.butler import Butler, LabeledButlerFactory from lsst.daf.butler.registry import RegistryDefaults +from lsst.dax.obscore import ExporterConfig from structlog.stdlib import BoundLogger from .config import Config @@ -26,6 +27,8 @@ class Factory: The configuration instance labeled_butler_factory The LabeledButlerFactory singleton + obscore_configs + The Obscore configurations logger The logger instance """ @@ -34,10 +37,12 @@ def __init__( self, config: Config, labeled_butler_factory: LabeledButlerFactory, + obscore_configs: dict[str, ExporterConfig], logger: BoundLogger | None = None, ) -> None: self._config = config self._labeled_butler_factory = labeled_butler_factory + self._obscore_configs = obscore_configs self._logger = ( logger if logger else structlog.get_logger(self._config.name) ) @@ -71,6 +76,21 @@ def create_butler( ) return butler + def create_obscore_config(self, label: str) -> ExporterConfig: + """Create an Obscore config object for a given label. + + Parameters + ---------- + label + The label for the Obscore config. + + Returns + ------- + ExporterConfig + The Obscore config. + """ + return self._obscore_configs[label] + def create_data_collection_service(self) -> DataCollectionService: """Create a data collection service. diff --git a/src/sia/services/response_handler.py b/src/sia/services/response_handler.py index 3f5bd3f..6d4489b 100644 --- a/src/sia/services/response_handler.py +++ b/src/sia/services/response_handler.py @@ -1,15 +1,18 @@ """Module for the Query Processor service.""" from collections.abc import Callable +from pathlib import Path import astropy import structlog from fastapi import Request +from fastapi.templating import Jinja2Templates from lsst.daf.butler import Butler from lsst.dax.obscore import ExporterConfig from lsst.dax.obscore.siav2 import SIAv2Parameters from starlette.responses import Response +from ..constants import BASE_RESOURCE_IDENTIFIER from ..constants import RESULT_NAME as RESULT from ..factory import Factory from ..models.data_collections import ButlerDataCollection @@ -22,10 +25,67 @@ astropy.io.votable.tree.VOTableFile, ] +BASE_DIR = Path(__file__).resolve().parent.parent +_TEMPLATES = Jinja2Templates(directory=str(Path(BASE_DIR, "templates"))) + class ResponseHandlerService: """Service for handling the SIAv2 query response.""" + @staticmethod + def self_description_response( + request: Request, + butler: Butler, + obscore_config: ExporterConfig, + butler_collection: ButlerDataCollection, + ) -> Response: + """Return a self-description response for the SIAv2 service. + This should provide metadata about the expected parameters and return + values for the service. + + Parameters + ---------- + request + The request object. + butler + The Butler instance. + obscore_config + The ObsCore configuration. + butler_collection + The Butler data collection. + + Returns + ------- + Response + The response containing the self-description. + """ + return _TEMPLATES.TemplateResponse( + request, + "self_description.xml", + { + "request": request, + "instruments": [ + rec.name + for rec in butler.query_dimension_records("instrument") + ], + "collections": [obscore_config.obs_collection], + # This may need to be updated if we decide to change the + # dax_obscore config to hold multiple collections + "resource_identifier": f"{BASE_RESOURCE_IDENTIFIER}/" + f"{butler_collection.label}" + f"{butler_collection.name}", + "access_url": request.url_for( + "query", collection_name=butler_collection.name + ), + "facility_name": obscore_config.facility_name.strip(), + }, + headers={ + "content-disposition": f"attachment; filename={RESULT}.xml", + "Content-Type": "application/x-votable+xml", + }, + media_type="application/x-votable+xml", + ) + @staticmethod def process_query( *, @@ -68,11 +128,20 @@ def process_query( butler_collection=collection, token=token, ) + obscore_config = factory.create_obscore_config(collection.label) + + if params.maxrec == 0: + return ResponseHandlerService.self_description_response( + request=request, + butler=butler, + obscore_config=obscore_config, + butler_collection=collection, + ) # Execute the query table_as_votable = sia_query( butler, - collection.get_exporter_config(), + obscore_config, params, ) diff --git a/src/sia/templates/self_description.xml b/src/sia/templates/self_description.xml new file mode 100644 index 0000000..99702ac --- /dev/null +++ b/src/sia/templates/self_description.xml @@ -0,0 +1,201 @@ + + + + Self description and list of supported parameters + + + Energy bounds + + + Calibration level + + + + + Collection name + + {%- for collection in collections %} + + + + Value of dataproduct type + + + + + Range of exposure times + + + Facility (telescope) name + + + + + Format content type for image file + + + + + Field of view + + + Observation ID + + + Instrument name + + {%- for instrument in instruments %} + + + + Polarization states + + + Circle region to be searched + + + Range region to be searched + + + Polygon region to be searched + + + Format of response + + + + + Specify position resolution + + + Specify energy resolving power + + + Target name + + + Time intervals + + + Time resolution + + + + + + + + + + + Central Spatial Position in ICRS Right ascension + + + Central Spatial Position in ICRS Declination + + + Name of telescope used to acquire observation + + + Name of instrument used to acquire observation + + + Product type (e.g. science, calibration, auxiliary, preview, info) + + + Calibration level of the observation: in {0, 1, 2, 3, 4} + + + Data product (file content) primary type + + + Internal ID given by the ObsTAP service + + + Spatial resolution (FWHM) + + + Lower bound on energy axis (barycentric wavelength) + + + Upper bound on energy axis (barycentric wavelength) + + + Spectral resolving power (R) + + + URI for the physical artifact + + + Content-Type of the representation at uri + + + Exposure time per pixel + + + AstroCoordArea Region covered in STC + + + Data collection this observation belongs to + + + Name of intended target + + + Dimensions (number of pixels) along first spatial axis + + + Dimensions (number of pixels) along second spatial axis + + + Lower bound on time axis (Modified Julian Day) + + + Upper bound on time axis (Modified Julian Day) + + + Resolution on the time axis + + + Dimensions (number of pixels) along the time axis + + + IVOA Dataset ID given by the publisher + + + Estimated size of the covered region as the diameter of a containing circle + + + Dimensions (number of pixels) along the energy axis + + + Dimensions (number of pixels) along the polarization axis + + + UCD describing the observable axis (pixel values) + + + + + +
+
+
\ No newline at end of file diff --git a/tests/handlers/external/external_test.py b/tests/handlers/external/external_test.py index 424602c..24d49fe 100644 --- a/tests/handlers/external/external_test.py +++ b/tests/handlers/external/external_test.py @@ -3,9 +3,11 @@ from __future__ import annotations import re +from pathlib import Path from typing import Any import pytest +from fastapi.templating import Jinja2Templates from httpx import AsyncClient from sia.config import config @@ -223,3 +225,32 @@ async def test_query_endpoint_post( ) elif expected_status == 400: validate_votable_error(response, expected_message) + + +@pytest.mark.asyncio +async def test_query_maxrec_zero( + client_direct: AsyncClient, +) -> None: + response = await client_direct.get( + f"{config.path_prefix}/hsc/query?MAXREC=0" + ) + + template_dir = str( + Path(__file__).resolve().parent.parent.parent / "templates" + ) + templates_dir = Jinja2Templates(template_dir) + + context = { + "instruments": ["HSC"], + "collections": ["LSST.CI"], + "resource_identifier": "ivo://rubin//ci_hsc_gen3hsc", + "access_url": "https://example.com/api/sia/hsc/query", + "facility_name": "Subaru", + } + + template_rendered = templates_dir.get_template( + "self_description.xml" + ).render(context) + + assert response.status_code == 200 + assert response.text.strip() == template_rendered.strip() diff --git a/tests/templates/self_description.xml b/tests/templates/self_description.xml new file mode 100644 index 0000000..99702ac --- /dev/null +++ b/tests/templates/self_description.xml @@ -0,0 +1,201 @@ + + + + Self description and list of supported parameters + + + Energy bounds + + + Calibration level + + + + + Collection name + + {%- for collection in collections %} + + + + Value of dataproduct type + + + + + Range of exposure times + + + Facility (telescope) name + + + + + Format content type for image file + + + + + Field of view + + + Observation ID + + + Instrument name + + {%- for instrument in instruments %} + + + + Polarization states + + + Circle region to be searched + + + Range region to be searched + + + Polygon region to be searched + + + Format of response + + + + + Specify position resolution + + + Specify energy resolving power + + + Target name + + + Time intervals + + + Time resolution + + + + + + + + + + + Central Spatial Position in ICRS Right ascension + + + Central Spatial Position in ICRS Declination + + + Name of telescope used to acquire observation + + + Name of instrument used to acquire observation + + + Product type (e.g. science, calibration, auxiliary, preview, info) + + + Calibration level of the observation: in {0, 1, 2, 3, 4} + + + Data product (file content) primary type + + + Internal ID given by the ObsTAP service + + + Spatial resolution (FWHM) + + + Lower bound on energy axis (barycentric wavelength) + + + Upper bound on energy axis (barycentric wavelength) + + + Spectral resolving power (R) + + + URI for the physical artifact + + + Content-Type of the representation at uri + + + Exposure time per pixel + + + AstroCoordArea Region covered in STC + + + Data collection this observation belongs to + + + Name of intended target + + + Dimensions (number of pixels) along first spatial axis + + + Dimensions (number of pixels) along second spatial axis + + + Lower bound on time axis (Modified Julian Day) + + + Upper bound on time axis (Modified Julian Day) + + + Resolution on the time axis + + + Dimensions (number of pixels) along the time axis + + + IVOA Dataset ID given by the publisher + + + Estimated size of the covered region as the diameter of a containing circle + + + Dimensions (number of pixels) along the energy axis + + + Dimensions (number of pixels) along the polarization axis + + + UCD describing the observable axis (pixel values) + + + + + +
+
+
\ No newline at end of file