diff --git a/sds_data_manager/lambda_code/SDSCode/api_lambdas/non_spice_table_api.py b/sds_data_manager/lambda_code/SDSCode/api_lambdas/non_spice_table_api.py index 936a271e4..93b575d3b 100644 --- a/sds_data_manager/lambda_code/SDSCode/api_lambdas/non_spice_table_api.py +++ b/sds_data_manager/lambda_code/SDSCode/api_lambdas/non_spice_table_api.py @@ -32,6 +32,7 @@ def lambda_handler(event, context): # noqa: PLR0912 else: response = { "statusCode": 400, + "headers": {"Content-Type": "application/json"}, "body": json.dumps( "Invalid path. Path must contain either 'spin' or 'repoint'." ), @@ -61,6 +62,7 @@ def lambda_handler(event, context): # noqa: PLR0912 if param not in valid_parameters: response = { "statusCode": 400, + "headers": {"Content-Type": "application/json"}, "body": json.dumps( f"{param} is not a valid query parameter. " + f"Valid query parameters are: {valid_parameters}" @@ -115,6 +117,7 @@ def lambda_handler(event, context): # noqa: PLR0912 except ValueError: response = { "statusCode": 400, + "headers": {"Content-Type": "application/json"}, "body": json.dumps(f"Invalid value for {param}: {value}"), } logger.debug(f"Invalid value for {param}: {value}") @@ -133,7 +136,11 @@ def lambda_handler(event, context): # noqa: PLR0912 } for result in search_results ] - return {"statusCode": 200, "body": json.dumps(search_results)} + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(search_results), + } # Spin or small-forces files have a start_date field search_results = [ @@ -146,4 +153,8 @@ def lambda_handler(event, context): # noqa: PLR0912 } for result in search_results ] - return {"statusCode": 200, "body": json.dumps(search_results)} + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(search_results), + } diff --git a/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_metakernel_api.py b/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_metakernel_api.py index b3df34f1f..36b7c9c4d 100644 --- a/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_metakernel_api.py +++ b/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_metakernel_api.py @@ -10,7 +10,6 @@ import spiceypy -from ..spice_utilities import furnish_best_spice_file from . import spice_query_api from .metakernel import MetaKernel @@ -196,6 +195,8 @@ def _convert_input_times_to_j2000(start_date_str, end_date_str): logger.info( "Attempting to load leapseconds kernel needed for time conversion." ) + from ..spice_utilities import furnish_best_spice_file # noqa: PLC0415 + furnish_best_spice_file("leapseconds") # Convert datetime to J2000 using spiceypy diff --git a/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_query_api.py b/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_query_api.py index f93c84f88..b23ea4495 100644 --- a/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_query_api.py +++ b/sds_data_manager/lambda_code/SDSCode/api_lambdas/spice_query_api.py @@ -9,11 +9,19 @@ from ..database import database as db from ..database import models +from . import non_spice_table_api # Logger setup logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) +# Map the 'type' param in this API to the rawPath that `non_spice_table_api` expects. +_NON_SPICE_RAW_PATHS = { + "spin": "/spin-table", + "repoint": "/repoint-table", + "thruster": "/small-forces-table", +} + def lambda_handler(event, context): """Entry point to the SPICE query API lambda. @@ -28,11 +36,53 @@ def lambda_handler(event, context): information about the invocation, function, and runtime environment. + Notes + ----- + The optional ``type`` query parameter controls which table is queried: + + * ``spin`` - spin files table + * ``repoint`` - repoint files table + * ``thruster`` - small-forces (thruster) files table + * ``kernels`` - All supported SPICE kernels types + * ```` - A specific SPICE kernel type from the SPICE files table + + Passing a kernel-type value such as ``attitude_history`` filters the SPICE table + by ``kernel_type``. The remaining SPICE-specific parameters + (``file_name``, ``start_time``, ``end_time``, ``latest``, + ``start_ingest_date``, ``end_ingest_date``) apply. + + When ``type`` is one of the non-SPICE values, the ``file_name`` parameter is + renamed to ``file_path``, ``start_time`` is mapped to ``start_date``, ``end_time`` + is mapped to ``end_date``, and the remaining parameters are passed to the + non-SPICE API. """ logger.debug("SPICE Query Event: " + json.dumps(event, indent=2)) + query_params = event.get("queryStringParameters") or {} + table_type = query_params.get("type", "kernels") + + if table_type in _NON_SPICE_RAW_PATHS: + # Remove `type`, since it is not a valid non-SPICE query parameter. + query_params.pop("type") + + # Remap incoming parameters to ones supported by the non-SPICE tables API. + try: + query_params = _remap_to_non_spice_params(query_params) + except ValueError: + return { + "statusCode": 400, + "body": json.dumps("Expected start/end times in ET."), + } + + return non_spice_table_api.lambda_handler( + { + "rawPath": _NON_SPICE_RAW_PATHS[table_type], + "queryStringParameters": query_params, + }, + context, + ) + # add session, pick model like in indexer and add query to filter_as - query_params = event["queryStringParameters"] with db.Session() as session: # select the SPICE files table for the query query = select(models.SPICEFiles) @@ -69,7 +119,7 @@ def lambda_handler(event, context): query = query.where(models.SPICEFiles.max_date_j2000 >= int(value)) elif param == "end_time": query = query.where(models.SPICEFiles.min_date_j2000 <= int(value)) - elif param == "type": + elif param == "type" and value != "kernels": query = query.where(models.SPICEFiles.kernel_type == value) elif param == "file_name": query = query.where(models.SPICEFiles.file_name == value) @@ -130,6 +180,36 @@ def lambda_handler(event, context): return response +def _remap_to_non_spice_params(query_params: dict) -> dict: + """Remap SPICE query parameters to those supported by the non-SPICE tables API.""" + if "file_name" in query_params: + query_params["file_path"] = query_params.pop("file_name") + + if "start_time" in query_params or "end_time" in query_params: + import spiceypy # noqa: PLC0415 + + try: + spiceypy.et2datetime(0) + except spiceypy.utils.exceptions.SpiceMISSINGTIMEINFO: + from ..spice_utilities import furnish_best_spice_file # noqa: PLC0415 + + furnish_best_spice_file("leapseconds") + + try: + if "start_time" in query_params: + query_params["start_date"] = spiceypy.et2datetime( + float(query_params.pop("start_time")) + ).strftime("%Y%m%d") + if "end_time" in query_params: + query_params["end_date"] = spiceypy.et2datetime( + float(query_params.pop("end_time")) + ).strftime("%Y%m%d") + except spiceypy.utils.exceptions.SpiceError as e: + raise ValueError(f"Invalid ET value for start_time/end_time: {e}") from e + + return query_params + + def _convert_spice_metadata_model_to_dict(file: models.SPICEFiles) -> dict: """Convert a sqlalchemy query to SPICEFiles to a dictionary. diff --git a/tests/lambda_endpoints/test_spice_query_api.py b/tests/lambda_endpoints/test_spice_query_api.py index d929cad1a..718c96cd3 100644 --- a/tests/lambda_endpoints/test_spice_query_api.py +++ b/tests/lambda_endpoints/test_spice_query_api.py @@ -261,3 +261,23 @@ def test_ingest_time_queries(session): } returned_query = spice_query_api.lambda_handler(event=event, context={}) assert len(json.loads(returned_query["body"])) == 1 + + +def test_type_query(session, expected_ck_response): + """Test if the `type` query parameter works as expected.""" + _insert_ck_test_data(session) + + # Record returned with no `type` filter + event = {"queryStringParameters": {}} + returned_query = spice_query_api.lambda_handler(event=event, context={}) + assert returned_query["body"] == expected_ck_response + + # Record returned with `type` filter set to `kernels`. + event = {"queryStringParameters": {"type": "kernels"}} + returned_query = spice_query_api.lambda_handler(event=event, context={}) + assert returned_query["body"] == expected_ck_response + + # Record returned with `type` filter set to the kernel type. + event = {"queryStringParameters": {"type": "attitude_history"}} + returned_query = spice_query_api.lambda_handler(event=event, context={}) + assert returned_query["body"] == expected_ck_response diff --git a/tests/lambda_endpoints/test_spin_api.py b/tests/lambda_endpoints/test_spin_api.py index 0baa16c99..69ce74545 100644 --- a/tests/lambda_endpoints/test_spin_api.py +++ b/tests/lambda_endpoints/test_spin_api.py @@ -4,12 +4,17 @@ import json import os import sys +from pathlib import Path import pytest +import spiceypy # Add the project root to the path to allow imports sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) -from sds_data_manager.lambda_code.SDSCode.api_lambdas import non_spice_table_api +from sds_data_manager.lambda_code.SDSCode.api_lambdas import ( + non_spice_table_api, + spice_query_api, +) from sds_data_manager.lambda_code.SDSCode.database.models import ( RepointFiles, SmallForcesFile, @@ -295,3 +300,116 @@ def test_small_forces_table(small_forces_db): for result in results: assert result["start_date"].startswith("2025-04-10") assert result["end_date"].startswith("2025-04-20") + + +# Tests when non-SPICE tables are accessed via `spice_query_api` + + +def test_spin_via_spice_query_api(spin_db): + """Test spin table query via the spice query API.""" + event = { + "queryStringParameters": { + "type": "spin", + "start_date": "20260925", + "end_date": "20260925", + }, + } + response = spice_query_api.lambda_handler(event, {}) + + assert response["statusCode"] == 200 + results = json.loads(response["body"]) + assert len(results) == 1 + assert ( + results[0]["file_path"] == "imap/spice/spin/imap_2026_268_2026_268_01.spin.csv" + ) + assert results[0]["start_date"].startswith("2026-09-25") + + +def test_repoint_via_spice_query_api(repoint_db): + """Test repoint table query via the spice query API.""" + event = { + "queryStringParameters": { + "type": "repoint", + "end_date": "20260925", + }, + } + response = spice_query_api.lambda_handler(event, {}) + + assert response["statusCode"] == 200 + results = json.loads(response["body"]) + assert len(results) == 1 + assert ( + results[0]["file_path"] + == "imap/spice/repoint/imap_2026_267_2026_268_02.repoint" + ) + assert results[0]["end_date"].startswith("2026-09-25") + + +def test_thruster_via_spice_query_api(small_forces_db): + """Test small-forces table query via the spice query API.""" + event = { + "queryStringParameters": { + "type": "thruster", + "start_date": "20250410", + "end_date": "20250420", + }, + } + response = spice_query_api.lambda_handler(event, {}) + + assert response["statusCode"] == 200 + results = json.loads(response["body"]) + assert len(results) == 2 + file_paths = [r["file_path"] for r in results] + assert "imap/spice/small-forces/imap_2025_100_2025_110_hist_01.sff" in file_paths + assert "imap/spice/small-forces/imap_2025_100_2025_110_hist_02.sff" in file_paths + + +def test_file_name_renamed_to_file_path(spin_db): + """The query param `file_name` is translated to `file_path` for non-SPICE tables.""" + event = { + "queryStringParameters": { + "type": "spin", + "file_name": "imap/spice/spin/imap_2026_268_2026_268_01.spin.csv", + }, + } + response = spice_query_api.lambda_handler(event, {}) + + assert response["statusCode"] == 200 + results = json.loads(response["body"]) + assert len(results) == 1 + assert ( + results[0]["file_path"] == "imap/spice/spin/imap_2026_268_2026_268_01.spin.csv" + ) + + +def test_thruster_start_end_time_via_spice_query_api(small_forces_db): + """Test small-forces table query using start/end time via the spice query API.""" + tests_path = Path(os.path.abspath(__file__)).parent.parent + test_spice_data_dir = tests_path / "test-data" / "test_spice_files" + with spiceypy.KernelPool([str(test_spice_data_dir / "naif0012.tls")]): + start_time = spiceypy.datetime2et( + datetime.datetime.strptime("20250410", "%Y%m%d") + ) + end_time = spiceypy.datetime2et( + datetime.datetime.strptime("20250420", "%Y%m%d") + ) + + event = { + "queryStringParameters": { + "type": "thruster", + "start_time": f"{start_time}", + "end_time": f"{end_time}", + }, + } + response = spice_query_api.lambda_handler(event, {}) + + assert response["statusCode"] == 200 + results = json.loads(response["body"]) + assert len(results) == 2 + file_paths = [r["file_path"] for r in results] + assert ( + "imap/spice/small-forces/imap_2025_100_2025_110_hist_01.sff" in file_paths + ) + assert ( + "imap/spice/small-forces/imap_2025_100_2025_110_hist_02.sff" in file_paths + )