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 %}
+
+ {%- endfor %}
+
+
+
+ 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 %}
+
+ {%- endfor %}
+
+
+
+ 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 %}
+
+ {%- endfor %}
+
+
+
+ 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 %}
+
+ {%- endfor %}
+
+
+
+ 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)
+
+
+
+
+
+