Skip to content

Commit

Permalink
Enable query POST, add some tests
Browse files Browse the repository at this point in the history
  • Loading branch information
stvoutsin committed Sep 30, 2024
1 parent 66d3bfe commit 2430cb0
Show file tree
Hide file tree
Showing 14 changed files with 898 additions and 88 deletions.
1 change: 1 addition & 0 deletions requirements/main.in
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
fastapi
starlette
uvicorn[standard]
python-multipart

# Other dependencies.
requests
Expand Down
12 changes: 7 additions & 5 deletions requirements/main.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ idna==3.10
# requests
jinja2==3.1.4
# via -r requirements/main.in
lsst-daf-butler @ git+https://github.com/lsst/daf_butler@8c62bcfed2e13399dc93963b30ce8fb958dbec0e
lsst-daf-butler @ git+https://github.com/lsst/daf_butler@ca923c07e74625de8057a45616ee1ec596cfc1e3
# via
# -r requirements/main.in
# lsst-dax-obscore
lsst-daf-relation==27.2024.3800
lsst-daf-relation==27.2024.3900
# via lsst-daf-butler
lsst-dax-obscore @ git+https://github.com/lsst-dm/dax_obscore.git@98f463dcdff39c5ac578cd7c78ec811793ca7206
lsst-dax-obscore @ git+https://github.com/lsst-dm/dax_obscore.git@f625c28047bc21e22f5898dd3eeb9ae7c0868366
# via -r requirements/main.in
lsst-felis==27.2024.3800
lsst-felis==27.2024.3900
# via lsst-dax-obscore
lsst-resources==27.2024.3600
# via
Expand All @@ -88,7 +88,7 @@ lsst-sphgeom==27.2024.3700
# via
# lsst-daf-butler
# lsst-dax-obscore
lsst-utils==27.2024.3800
lsst-utils==27.2024.3900
# via
# lsst-daf-butler
# lsst-daf-relation
Expand Down Expand Up @@ -145,6 +145,8 @@ python-dotenv==1.0.1
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.10
# via -r requirements/main.in
pyyaml==6.0.2
# via
# astropy
Expand Down
2 changes: 1 addition & 1 deletion requirements/tox.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ tox==4.20.0
# tox-uv
tox-uv==1.13.0
# via -r requirements/tox.in
uv==0.4.15
uv==0.4.16
# via tox-uv
virtualenv==20.26.5
# via tox
9 changes: 8 additions & 1 deletion src/vosiav2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"RESPONSEFORMATS",
"COLLECTIONS",
"RESULT_NAME",
"SINGLE_PARAMS",
]

from .models.data_collections import DataCollection
Expand All @@ -25,5 +26,11 @@
default=True,
),
]

"""Configuration for a query engine."""


SINGLE_PARAMS = {
"maxrec",
"responseformat",
}
"""Parameters that should be treated as single values."""
63 changes: 63 additions & 0 deletions src/vosiav2/dependencies/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
ParamFactory.
"""

from collections import defaultdict

from fastapi import Request

from ..config import config
from ..constants import SINGLE_PARAMS
from ..factories.param_factory import ParamFactory
from ..factories.query_engine_factory import QueryEngineFactory
from ..models import SIAv2QueryParams


def get_query_engine_factory() -> QueryEngineFactory:
Expand All @@ -15,3 +21,60 @@ def get_query_engine_factory() -> QueryEngineFactory:
def get_param_factory() -> ParamFactory:
"""Return a ParamFactory instance."""
return ParamFactory(config)


async def siav2_post_params_dependency(
request: Request,
) -> SIAv2QueryParams:
"""Dependency to parse the POST parameters for the SIAv2 query.
Parameters
----------
request
The request object.
Returns
-------
SIAv2QueryParams
The parameters for the SIAv2 query.
Raises
------
ValueError
If the method is not POST.
"""
if request.method != "POST":
raise ValueError(
"siav2_post_params_dependency used for non-POST route"
)
content_type = request.headers.get("Content-Type", "")
params_dict: dict[str, list[str]] = defaultdict(list)

# Handle JSON Content-Type
# This isn't required by the SIAv2 spec, but it may be useful for
# deugging, for future expansion the spec and for demonstration purposes.
if "application/json" in content_type:
json_data = await request.json()
for key, value in json_data.items():
lower_key = key.lower()
if isinstance(value, list):
params_dict[lower_key].extend(str(v) for v in value)
else:
params_dict[lower_key].append(str(value))

else: # Assume form data
form_data = await request.form()
for key, value in form_data.multi_items():
if not isinstance(value, str):
raise TypeError("File upload not supported")
lower_key = key.lower()
params_dict[lower_key].append(value)

converted_params_dict = {}
for key, value in params_dict.items():
if key in SINGLE_PARAMS:
converted_params_dict[key] = value[0]
else:
converted_params_dict[key] = value

return SIAv2QueryParams.from_dict(converted_params_dict)
155 changes: 108 additions & 47 deletions src/vosiav2/handlers/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,26 @@
from fastapi.templating import Jinja2Templates
from safir.dependencies.logger import logger_dependency
from safir.metadata import get_metadata
from safir.models import ErrorModel
from starlette.concurrency import run_in_threadpool
from starlette.responses import Response
from structlog.stdlib import BoundLogger
from vo_models.vosi.availability import Availability

from ..config import config
from ..constants import RESULT_NAME
from ..dependencies.availability import get_availability_dependency
from ..dependencies.query import get_param_factory, get_query_engine_factory
from ..dependencies.query import (
get_param_factory,
get_query_engine_factory,
siav2_post_params_dependency,
)
from ..dependencies.token import optional_auth_delegated_token_dependency
from ..exceptions import handle_exceptions
from ..factories.param_factory import ParamFactory
from ..factories.query_engine_factory import QueryEngineFactory
from ..models import Index, SIAv2QueryParams
from ..services.config_reader import (
get_data_collection,
get_default_collection,
)
from ..services.query_processor import process_query
from ..services.timer import timer
from ..services.votable import VOTableConverter

BASE_DIR = Path(__file__).resolve().parent.parent
_TEMPLATES = Jinja2Templates(directory=str(Path(BASE_DIR, "templates")))
Expand Down Expand Up @@ -168,7 +169,7 @@ async def get_capabilities(
"request": request,
"availability_url": request.url_for("get_availability"),
"capabilities_url": request.url_for("get_capabilities"),
"query_url": request.url_for("query"),
"query_url": request.url_for("query_get"),
},
media_type="application/xml",
)
Expand All @@ -177,12 +178,18 @@ async def get_capabilities(
@external_router.get(
"/query",
description="Query endpoint for the SIAv2 service.",
responses={200: {"content": {"application/xml": {}}}},
responses={
200: {"content": {"application/xml": {}}},
400: {
"description": "Invalid query parameters",
"model": ErrorModel,
},
},
summary="IVOA SIAv2 service query",
)
@timer
@handle_exceptions
def query(
def query_get(
query_engine_factory: Annotated[
QueryEngineFactory, Depends(get_query_engine_factory)
],
Expand All @@ -194,8 +201,8 @@ def query(
],
) -> Response:
"""Endpoint used to query the SIAv2 service using various
parameters defined in the SIAv2 spec. The response is an XML VOTable file
that adheres the Obscore model.
parameters defined in the SIAv2 spec via a GET request.
The response is an XML VOTable file that adheres the Obscore model.
Parameters
----------
Expand All @@ -215,49 +222,103 @@ def query(
Response
The response containing the query results.
## GET /api/siav2/query
**Example Query**:
Examples
--------
### GET /api/siav2/query
```
/api/siav2/query?POS=CIRCLE+321+0+1&BAND=700e-9&FORMAT=votable
/api/images/query?POS=CIRCLE+321+0+1&BAND=700e-9&FORMAT=votable
```
**Response**:
A VOTable XML response contains a table conforming to the ObsCore standard.
See Also
--------
SIAv2 Specification: http://www.ivoa.net/documents/SIA/
ObsCore Data Model: http://www.ivoa.net/documents/ObsCore/
"""
logger.info("Processing SIAv2 query with params:", params=params)

# Get the Butler collection configuration.
# If many collections are provided, for now just look at the first one.
# This needs to be updated to handle multiple collections.
collection = (
get_data_collection(label=params.collection[0], config=config)
if params.collection is not None and len(params.collection) > 0
else get_default_collection(config=config)
logger.info(
"SIAv2 query started with params:", params=params, method="GET"
)

# Create the query engine
query_engine = query_engine_factory.create_query_engine(
token=delegated_token, label=collection.label, config=collection.config
return process_query(
params=params,
query_engine_factory=query_engine_factory,
param_factory=param_factory,
delegated_token=delegated_token,
)

# Get the query params in the right format
query_params = param_factory.create_params(
siav2_params=params
).to_engine_parameters()

# Execute the query
table_as_votable = query_engine.siav2_query(query_params)
@handle_exceptions
@external_router.post(
"/query",
description="Query endpoint for the SIAv2 service (POST method).",
responses={
200: {"content": {"application/xml": {}}},
400: {
"description": "Invalid query parameters",
"model": ErrorModel,
},
},
summary="IVOA SIAv2 service query (POST)",
)
async def query_post(
*,
query_engine_factory: Annotated[
QueryEngineFactory, Depends(get_query_engine_factory)
],
params: Annotated[SIAv2QueryParams, Depends(siav2_post_params_dependency)],
param_factory: Annotated[ParamFactory, Depends(get_param_factory)],
logger: Annotated[BoundLogger, Depends(logger_dependency)],
delegated_token: Annotated[
str | None, Depends(optional_auth_delegated_token_dependency)
],
) -> Response:
"""Endpoint used to query the SIAv2 service using various
parameters defined in the SIAv2 spec via a POST request.
The response is an XML VOTable file that adheres the Obscore model.
# Convert the result to a string
result = VOTableConverter(table_as_votable).to_string()
Parameters
----------
param_factory
The Param factory dependency.
query_engine_factory
The Query Engine factory dependency.
delegated_token
The delegated token. (Optional)
params
The parameters for the SIAv2 query.
logger
The logger instance.
# For the moment only VOTable is supported, so we can hardcode the
# media_type and the file extension.
return Response(
headers={
"content-disposition": f"attachment; filename={RESULT_NAME}.xml",
"Content-Type": "application/x-votable+xml",
},
content=result,
media_type="application/x-votable+xml",
Returns
-------
Response
The response containing the query results.
Examples
--------
### POST /api/siav2/query
```
POST /api/images/query
Content-Type: application/json
{
"POS": "CIRCLE 321 0 1",
"BAND": "700e-9",
"FORMAT": "votable"
}
```
See Also
--------
SIAv2 Specification: http://www.ivoa.net/documents/SIA/
ObsCore Data Model: http://www.ivoa.net/documents/ObsCore/
"""
logger.info(
"SIAv2 query started with params:", params=params, method="POST"
)
return await run_in_threadpool(
process_query,
params=params,
query_engine_factory=query_engine_factory,
param_factory=param_factory,
delegated_token=delegated_token,
)
4 changes: 2 additions & 2 deletions src/vosiav2/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from .exceptions import configure_exception_handlers
from .handlers.external import external_router
from .handlers.internal import internal_router
from .middleware.ivoa import CaseInsensitiveQueryMiddleware
from .middleware.ivoa import CaseInsensitiveQueryAndBodyMiddleware

__all__ = ["app"]

Expand Down Expand Up @@ -59,7 +59,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""The main FastAPI application for vo-siav2."""

# Address case-sensitivity issue with IVOA query parameters
app.add_middleware(CaseInsensitiveQueryMiddleware)
app.add_middleware(CaseInsensitiveQueryAndBodyMiddleware)

# Configure exception handlers.
configure_exception_handlers(app)
Expand Down
Loading

0 comments on commit 2430cb0

Please sign in to comment.