forked from IMAP-Science-Operations-Center/sds-data-manager
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathquery_api.py
More file actions
304 lines (252 loc) · 9.85 KB
/
Copy pathquery_api.py
File metadata and controls
304 lines (252 loc) · 9.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
"""Contains the lambda handler for the 'query' data access API."""
import datetime
import json
import logging
from enum import StrEnum
from sqlalchemy import func, select
from ..api_lambdas.utils import build_latest_version_query, is_authenticated_user
from ..database import database as db
from ..database import models
# Logger setup
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# Maps the `table` query param to its model.
_TABLE_MODELS = {
"science": models.ScienceFiles,
"ancillary": models.AncillaryFiles,
"spice": models.SPICEFiles,
"quicklook": models.QuicklookFiles,
}
# Valid query parameters include...
# all table columns
# + ingestion_start_date/ingestion_end_date,
# + "end_date" for tables with a start_date but no end_date
# (science/quicklook have start_date only; ancillary has both, spice has neither).
_VALID_PARAMETERS = {
table: [
*model.__table__.c.keys(),
*(
["end_date"]
if "start_date" in model.__table__.c and "end_date" not in model.__table__.c
else []
),
"ingestion_start_date",
"ingestion_end_date",
]
for table, model in _TABLE_MODELS.items()
}
class LatestVersionMode(StrEnum):
"""The two ways a science query resolves "latest"."""
# single newest file per series (latest major, then latest minor)
NEWEST = "newest"
# every minor version of the latest major
LATEST_MAJOR = "latest_major"
def _parse_version_alias(value):
"""Translate a legacy ``version`` string into ``(major, minor)`` integers.
The ``version`` query parameter is kept for backwards compatibility after
the science ``version`` column was split into ``major_version`` and
``minor_version``. Accepts the full ``vMMM.mmmm`` form or the deprecated
minor-only ``vXXX`` form.
Parameters
----------
value : str
The ``version`` query parameter value.
Returns
-------
tuple
``(major, minor)`` where ``major`` is ``None`` for the minor-only form.
"""
digits = value.lstrip("vV")
if "." in digits:
major_str, minor_str = digits.split(".", 1)
return int(major_str), int(minor_str)
return None, int(digits)
def _resolve_science_version_mode(query_params):
"""Resolve science version params in place and return the latest mode.
Applies the backwards-compatible ``version`` alias and reads the ``latest``
flag, mutating ``query_params`` so the generic query loop only sees real
columns.
Parameters
----------
query_params : dict
The (mutable) query parameters; updated in place.
Returns
-------
LatestVersionMode or None
A ``LatestVersionMode`` value, or None when a concrete major_version
was requested (no latest restriction applied).
Raises
------
ValueError
If the ``version`` alias value cannot be parsed.
"""
# Backwards-compatible `version` alias -> minor_version (and major_version
# when the full vMMM.mmmm form is provided).
if "version" in query_params:
major, minor = _parse_version_alias(query_params.pop("version"))
if major is not None:
query_params["major_version"] = major
query_params["minor_version"] = minor
# `latest=true` -> the single newest file (latest major + latest minor).
latest_flag = str(query_params.pop("latest", "")).lower() == "true"
# Precedence: a concrete major_version wins (None -> no latest restriction),
# then latest=true, then the default (omitting major_version -> latest
# major, all minor versions).
if "major_version" in query_params:
return None
if latest_flag:
return LatestVersionMode.NEWEST
return LatestVersionMode.LATEST_MAJOR
def _filter_condition(cols, param, value):
"""Build the SQLAlchemy filter expression for one query parameter.
Parameters
----------
cols
The column collection to filter against.
param : str
The query parameter name.
value : str
The query parameter value.
Returns
-------
ColumnElement
A SQLAlchemy boolean clause to AND into the query.
"""
match param:
case "start_date":
return cols.start_date >= datetime.datetime.strptime(value, "%Y%m%d")
case "end_date":
# TODO: Need to discuss as a team how to handle date queries. For now,
# the date queries will only look at the file start_date.
return cols.start_date <= datetime.datetime.strptime(value, "%Y%m%d")
case "ingestion_start_date":
return func.date(cols.ingestion_date) >= (
datetime.datetime.strptime(value, "%Y%m%d").date()
)
case "ingestion_end_date":
return func.date(cols.ingestion_date) <= (
datetime.datetime.strptime(value, "%Y%m%d").date()
)
case _:
return cols[param] == value
def _format_search_results(search_results):
"""Stringify datetime fields in query results for the JSON response.
Parameters
----------
search_results : list
List of result dicts; mutated in place.
Returns
-------
list
The same list with date fields formatted as strings.
"""
for result in search_results:
if "major_version" in result and "minor_version" in result:
result["version"] = (
f"v{result['major_version']:03d}.{result['minor_version']:04d}"
)
result["start_date"] = result["start_date"].strftime("%Y%m%d")
if result.get("end_date"):
result["end_date"] = result["end_date"].strftime("%Y%m%d")
ingestion = result["ingestion_date"]
if ingestion.tzinfo is not None:
# Convert to UTC and drop the timezone.
ingestion = ingestion.astimezone(datetime.timezone.utc).replace(tzinfo=None)
result["ingestion_date"] = ingestion.strftime("%Y%m%d %H:%M:%S")
return search_results
def lambda_handler(event, context):
"""Entry point to the query API lambda.
Parameters
----------
event : dict
The JSON formatted document with the data required for the
lambda function to process
context : LambdaContext
This object provides methods and properties that provide
information about the invocation, function,
and runtime environment.
"""
logger.info(f"Event: {event}")
logger.info(f"Context: {context}")
logger.info("Received event: " + json.dumps(event, indent=2))
# Make a mutable copy so we can pre-process science version parameters.
query_params = dict(event["queryStringParameters"])
query_table = query_params.pop("table", "science")
logger.info(f"Querying table: {query_table}")
if query_table not in _TABLE_MODELS:
return {
"statusCode": 400,
"body": json.dumps(
f"{query_table} is not a valid table. "
f"Valid tables are: {list(_TABLE_MODELS)}"
),
}
model = _TABLE_MODELS[query_table]
table_columns = model.__table__.c
version_mode = None
if query_table == "science":
try:
version_mode = _resolve_science_version_mode(query_params)
except ValueError:
return {
"statusCode": 400,
"body": json.dumps(
"Invalid 'version' value. Use format 'vMMM.mmmm' or 'vXXX'."
),
}
valid_parameters = _VALID_PARAMETERS[query_table]
filters = []
for param, value in query_params.items():
if param not in valid_parameters:
response = {
"statusCode": 400,
"body": json.dumps(
f"{param} is not a valid query parameter for {query_table} table. "
+ f"Valid query parameters are: {valid_parameters}"
),
}
logger.debug(
f"Received an invalid query parameter [{param}] for table "
"{query_table}, valid options are: {valid_parameters}"
)
return response
filters.append(_filter_condition(table_columns, param, value))
# if not authenticated, restrict to released only
authenticated = is_authenticated_user(event)
if not authenticated:
filters.append(table_columns.released)
if version_mode is None:
# if not filtering by version, simply SELECT ... WHERE ...
query = select(model.__table__).where(*filters)
cols = table_columns
else:
# otherwise, also include the rank subquery
query = build_latest_version_query(
filters=filters,
major_only=version_mode == LatestVersionMode.LATEST_MAJOR,
)
# important not to use table_columns hereafter
cols = query.selected_columns
# We want to order the query returns by the filename
# This will implicitly sort by: instrument, data level, descriptor, start_date, ...
# Default for the table is by the ascending id so by insertion order
# This fails for the SPICE table because it uses 'file_name'
query = query.order_by(cols.file_path)
with db.Session() as session:
search_results = session.execute(query).all()
# Convert the search results (list of tuples) to a list of dicts and
# stringify their datetime fields for the JSON response.
search_results = [result._asdict() for result in search_results]
search_results = _format_search_results(search_results)
logger.info(
"Found [%s] Query Search Results: %s",
len(search_results),
str(search_results),
)
# Format the response
response = {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(search_results), # returns a list of tuples
}
return response