From 798bc384869145eabc822226f6e861dd39a8372c Mon Sep 17 00:00:00 2001 From: Stelios Voutsinas Date: Thu, 17 Oct 2024 15:31:53 -0700 Subject: [PATCH] Change to single shared query, single param dependency for post & get and add documentation for helper endpoints --- Dockerfile | 1 - ruff-shared.toml | 1 + src/sia/dependencies/query_params.py | 54 ++--- src/sia/handlers/external.py | 71 +++--- src/sia/models/sia_query_params.py | 349 +-------------------------- 5 files changed, 68 insertions(+), 408 deletions(-) diff --git a/Dockerfile b/Dockerfile index 96ce612..7354570 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,4 +65,3 @@ EXPOSE 8080 # Run the application. CMD ["uvicorn", "sia.main:app", "--host", "0.0.0.0", "--port", "8080"] - diff --git a/ruff-shared.toml b/ruff-shared.toml index 0702eaf..abbdbd9 100644 --- a/ruff-shared.toml +++ b/ruff-shared.toml @@ -125,6 +125,7 @@ select = ["ALL"] builtins-ignorelist = [ "all", "any", + "format", "help", "id", "list", diff --git a/src/sia/dependencies/query_params.py b/src/sia/dependencies/query_params.py index 59fdb0c..8bdfe75 100644 --- a/src/sia/dependencies/query_params.py +++ b/src/sia/dependencies/query_params.py @@ -1,44 +1,34 @@ """Provides functions to get instances of params.""" +from collections import defaultdict from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Request from lsst.dax.obscore.siav2 import SIAv2Parameters -from ..models.sia_query_params import SIAFormParams, SIAQueryParams +from ..constants import SINGLE_PARAMS +from ..models.sia_query_params import SIAQueryParams -def get_query_params( +async def get_sia_params_dependency( + *, params: Annotated[SIAQueryParams, Depends(SIAQueryParams)], + request: Request, ) -> SIAv2Parameters: - """Get the SIAv2Parameters from the query parameters. - - Parameters - ---------- - params - The query parameters. - - Returns - ------- - SIAv2Parameters - The SIAv2Parameters instance. - """ - return params.to_butler_parameters() - + """Parse GET and POST parameters into SIAv2Parameters for SIA query.""" + # For POST requests, use form data + if request.method == "POST": + post_params_ddict: dict[str, list[str]] = defaultdict(list) + + for key, value in (await request.form()).multi_items(): + if not isinstance(value, str): + raise TypeError("File upload not supported") + post_params_ddict[key].append(value) + + post_params = { + key: (values[0] if key in SINGLE_PARAMS and values else values) + for key, values in post_params_ddict.items() + } + params = SIAQueryParams.from_dict(post_params) -def get_form_params( - params: Annotated[SIAFormParams, Depends(SIAFormParams)], -) -> SIAv2Parameters: - """Get the SIAv2Parameters from the form parameters. - - Parameters - ---------- - params - The form parameters. - - Returns - ------- - SIAv2Parameters - The SIAv2Parameters instance. - """ return params.to_butler_parameters() diff --git a/src/sia/handlers/external.py b/src/sia/handlers/external.py index b2a12ae..6e34111 100644 --- a/src/sia/handlers/external.py +++ b/src/sia/handlers/external.py @@ -3,19 +3,20 @@ from pathlib import Path from typing import Annotated -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, Response from fastapi.templating import Jinja2Templates from lsst.dax.obscore.siav2 import SIAv2Parameters, siav2_query from safir.dependencies.logger import logger_dependency from safir.metadata import get_metadata from safir.models import ErrorModel -from starlette.responses import Response from structlog.stdlib import BoundLogger +from vo_models.vosi.availability import Availability +from vo_models.vosi.capabilities.models import VOSICapabilities from ..config import config from ..dependencies.context import RequestContext, context_dependency from ..dependencies.data_collections import validate_collection -from ..dependencies.query_params import get_form_params, get_query_params +from ..dependencies.query_params import get_sia_params_dependency from ..dependencies.token import optional_auth_delegated_token_dependency from ..models.data_collections import ButlerDataCollection from ..models.index import Index @@ -70,8 +71,23 @@ async def get_index( @external_router.get( "/{collection_name}/availability", + response_model=Availability, description="VOSI-availability resource for the service", - responses={200: {"content": {"application/xml": {}}}}, + responses={ + 200: { + "description": "Successful Response", + "content": { + "application/xml": { + "example": """ + + true +""", + "schema": Availability.model_json_schema(), + }, + "application/json": None, + }, + } + }, summary="IVOA service availability", ) async def get_availability(collection_name: str) -> Response: @@ -92,7 +108,27 @@ async def get_availability(collection_name: str) -> Response: @external_router.get( "/{collection_name}/capabilities", description="VOSI-capabilities resource for the SIA service.", - responses={200: {"content": {"application/xml": {}}}}, + response_model=VOSICapabilities, + responses={ + 200: { + "content": { + "application/xml": { + "example": """ + + + + https://example.com/query + + + """, + "schema": VOSICapabilities.model_json_schema(), + } + } + } + }, summary="IVOA service capabilities", ) async def get_capabilities( @@ -111,7 +147,7 @@ async def get_capabilities( "get_capabilities", collection_name=collection_name ), "query_url": request.url_for( - "query_get", collection_name=collection_name + "query", collection_name=collection_name ), }, media_type="application/xml", @@ -130,25 +166,6 @@ async def get_capabilities( }, summary="IVOA SIA service query", ) -def query_get( - *, - context: Annotated[RequestContext, Depends(context_dependency)], - collection: Annotated[ButlerDataCollection, Depends(validate_collection)], - params: Annotated[SIAv2Parameters, Depends(get_query_params)], - delegated_token: Annotated[ - str | None, Depends(optional_auth_delegated_token_dependency) - ], -) -> Response: - return ResponseHandlerService.process_query( - factory=context.factory, - params=params, - token=delegated_token, - sia_query=siav2_query, - collection=collection, - request=context.request, - ) - - @external_router.post( "/{collection_name}/query", description="Query endpoint for the SIA service (POST method).", @@ -161,11 +178,11 @@ def query_get( }, summary="IVOA SIA (v2) service query (POST)", ) -def query_post( +async def query( *, context: Annotated[RequestContext, Depends(context_dependency)], collection: Annotated[ButlerDataCollection, Depends(validate_collection)], - params: Annotated[SIAv2Parameters, Depends(get_form_params)], + params: Annotated[SIAv2Parameters, Depends(get_sia_params_dependency)], delegated_token: Annotated[ str | None, Depends(optional_auth_delegated_token_dependency) ], diff --git a/src/sia/models/sia_query_params.py b/src/sia/models/sia_query_params.py index c8ea655..a55bd6c 100644 --- a/src/sia/models/sia_query_params.py +++ b/src/sia/models/sia_query_params.py @@ -7,7 +7,7 @@ from numbers import Integral from typing import Annotated, Any, Self, TypeVar, cast -from fastapi import Form, Query +from fastapi import Query from lsst.dax.obscore.siav2 import SIAv2Parameters from ..exceptions import UsageFaultError @@ -16,7 +16,6 @@ __all__ = [ "BaseQueryParams", "SIAQueryParams", - "SIAFormParams", "Shape", "DPType", "Polarization", @@ -313,49 +312,6 @@ def from_dict(cls, data: dict[str, Any]) -> Self: """ return cls(**data) - @classmethod - def validate_enum_list( - cls, - value: str | int | T | list[str | int | T] | list[T] | None, - enum_class: type[T], - field_name: str, - ) -> list[T] | None: - """Validate a list of enum values. - - Parameters - ---------- - value - The value to validate. - enum_class - The enumeration class. - field_name - The field name - - Returns - ------- - list - The validated list of enum values. - - Raises - ------ - ValueError - If the value is not a list. - """ - if value is None: - return None - if not isinstance(value, list): - value = [value] - - try: - return [ - enum_class(item) if isinstance(item, str | int) else item - for item in value - ] - except ValueError as exc: - raise UsageFaultError( - detail=f"Validation of '{field_name}' failed" - ) from exc - def all_params_none(self) -> bool: """Check if all params except maxrec and responseformat are None.""" return all( @@ -366,16 +322,6 @@ def all_params_none(self) -> bool: def __post_init__(self) -> None: """Validate the form parameters.""" - self.pol = self.validate_enum_list( - value=self.pol, enum_class=Polarization, field_name="pol" - ) - self.dptype = self.validate_enum_list( - value=self.dptype, enum_class=DPType, field_name="dptype" - ) - self.calib = self.validate_enum_list( - value=self.calib, enum_class=CalibLevel, field_name="calib" - ) - # If no parameters were provided, I don't think we should run a query # Instead return the self-description VOTable if self.all_params_none(): @@ -435,296 +381,3 @@ def _convert_calib(calib: list[CalibLevel] | None) -> Iterable[Integral]: if calib is None: return () return cast(list[Integral], [int(level.value) for level in calib]) - - -@dataclass -class SIAFormParams(BaseQueryParams): - """A class to represent the form parameters for an SIA query. - - Attributes - ---------- - pos - Positional region(s) to be searched. - format - Image format(s). - time - Time interval(s) to be searched. - band - Band interval(s) to be searched. - pol - Polarization state(s) to be searched. - fov - Range(s) of field of view. - spatres - Range(s) of spatial resolution. - exptime - Range(s) of exposure times. - timeres - Range(s) of temporal resolution. - specrp - Range(s) of spectral resolving power. - id - Identifier of dataset(s). (Case insensitive) - dptype - Type of data (dataproduct_type). - calib - Calibration level of the data. - target - Name of the target. - collection - Name of the data collection. - facility - Name of the facility. - instrument - Name of the instrument. - maxrec - Maximum number of records in the response. - responseformat - Format of the response. - """ - - pos: Annotated[ - list[str] | None, - Form( - title="pos", - description="Positional region(s) to be searched", - examples=["55.7467 -32.2862 0.05"], - ), - ] = None - - format: Annotated[ - list[str] | None, - Form( - title="format", - alias="format", - description="Response format(s)", - examples=["application/x-votable+xml"], - ), - ] = None - - time: Annotated[ - list[str] | None, - Form( - title="time", - description="Time interval(s) to be searched", - examples=["60550.31803461111 60550.31838182871"], - ), - ] = None - - band: Annotated[ - list[str] | None, - Form( - title="band", - description="Energy interval(s) to be searched", - examples=["0.1 10.0"], - ), - ] = None - - pol: Annotated[ - list[Polarization] | None, - Form( - title="pol", - description="Polarization state(s) to be searched", - examples=["I", "Q"], - ), - ] = None - - fov: Annotated[ - list[str] | None, - Form( - title="fov", - description="Range(s) of field of view", - examples=["1.0 2.0"], - ), - ] = None - - spatres: Annotated[ - list[str] | None, - Form( - title="spatres", - description="Range(s) of spatial resolution", - examples=["0.1 0.2"], - ), - ] = None - - exptime: Annotated[ - list[str] | None, - Form( - title="exptime", - description="Range(s) of exposure times", - examples=["-Inf 60"], - ), - ] = None - - timeres: Annotated[ - list[str] | None, - Form( - title="timeres", - description="Range(s) of temporal resolution", - examples=["-Inf 1.0"], - ), - ] = None - - specrp: Annotated[ - list[str] | None, - Form( - title="specrp", - description="Range(s) of spectral resolving power", - examples=["1000 2000"], - ), - ] = None - - id: Annotated[ - list[str] | None, - Form( - title="id", - alias="id", - description="Identifier of dataset(s)", - examples=["obs_id_1"], - ), - ] = None - - dptype: Annotated[ - list[DPType] | None, - Form(title="dptype", description="Type of data", examples=["image"]), - ] = None - - calib: Annotated[ - list[CalibLevel] | None, - Form( - title="calib", - description="Calibration level of the data", - examples=[0, 1, 2], - ), - ] = None - - target: Annotated[ - list[str] | None, - Form( - title="target", description="Name of the target", examples=["M31"] - ), - ] = None - - collection: Annotated[ - list[str] | None, - Form( - title="collection", - description="Name of the data collection", - examples=["HST"], - ), - ] = None - - facility: Annotated[ - list[str] | None, - Form( - title="facility", - description="Name of the facility", - examples=["HST"], - ), - ] = None - - instrument: Annotated[ - list[str] | None, - Form( - title="instrument", - description="Name of the instrument", - examples=["ACS"], - ), - ] = None - - maxrec: Annotated[ - int | None, - Form( - title="maxrec", - description="Maximum number of records in the response", - examples=[10], - ), - ] = None - - responseformat: Annotated[ - str | None, - Form( - title="responseformat", - description="Format of the response", - examples=["application/x-votable+xml"], - ), - ] = None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> Self: - """Create an instance of SIAFormParams from a dictionary.""" - return cls(**data) - - @classmethod - def validate_enum_list( - cls, - value: str | int | T | list[str | int | T] | list[T] | None, - enum_class: type[T], - field_name: str, - ) -> list[T] | None: - """Validate a list of enum values.""" - if value is None: - return None - if not isinstance(value, list): - value = [value] - - try: - return [ - enum_class(item) if isinstance(item, str | int) else item - for item in value - ] - except ValueError as exc: - raise UsageFaultError( - detail=f"Validation of '{field_name}' failed" - ) from exc - - def all_params_none(self) -> bool: - """Check if all params except maxrec and responseformat are None.""" - return all( - getattr(self, attr) is None - for attr in self.__annotations__ - if attr not in ["maxrec", "responseformat"] - ) - - def __post_init__(self) -> None: - """Validate the form parameters.""" - self.pol = self.validate_enum_list( - value=self.pol, enum_class=Polarization, field_name="pol" - ) - self.dptype = self.validate_enum_list( - value=self.dptype, enum_class=DPType, field_name="dptype" - ) - self.calib = self.validate_enum_list( - value=self.calib, enum_class=CalibLevel, field_name="calib" - ) - - # If no parameters were provided, I don't think we should run a query - # Instead return the self-description VOTable - if self.all_params_none(): - self.maxrec = 0 - - def to_dict(self) -> dict[str, Any]: - """Return the form parameters as a dictionary.""" - return {k: v for k, v in asdict(self).items() if v is not None} - - def to_butler_parameters(self) -> SIAv2Parameters: - """Convert the form parameters to SIAv2Parameters.""" - try: - return SIAv2Parameters.from_siav2( - instrument=self.instrument or (), - pos=self.pos or (), - time=self.time or (), - band=self.band or (), - exptime=self.exptime or (), - calib=self._convert_calib(calib=self.calib), - maxrec=str(self.maxrec) if self.maxrec is not None else None, - ) - except ValueError as exc: - raise UsageFaultError(detail=str(exc)) from exc - - @staticmethod - def _convert_calib(calib: list[CalibLevel] | None) -> Iterable[Integral]: - """Convert the calibration levels to integers.""" - if calib is None: - return () - return cast(list[Integral], [int(level.value) for level in calib])