Skip to content

Commit 75030a9

Browse files
committed
Merge branch 'main' into COPDS-2006-format-api-request
2 parents 85733f2 + 4f41fb7 commit 75030a9

File tree

8 files changed

+256
-77
lines changed

8 files changed

+256
-77
lines changed

cads_processing_api_service/auth.py

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import fastapi
2323
import requests
2424

25-
from . import config, costing, exceptions
25+
from . import config, costing, exceptions, models
2626

2727
VERIFICATION_ENDPOINT = {
2828
"PRIVATE-TOKEN": "/account/verification/pat",
@@ -183,17 +183,25 @@ def get_accepted_licences(auth_header: tuple[str, str]) -> set[tuple[str, int]]:
183183
return accepted_licences
184184

185185

186-
def check_licences(
187-
required_licences: set[tuple[str, int]], accepted_licences: set[tuple[str, int]]
186+
def verify_licences(
187+
accepted_licences: set[tuple[str, int]] | list[tuple[str, int]],
188+
required_licences: set[tuple[str, int]] | list[tuple[str, int]],
189+
api_request_url: str,
190+
process_id: str,
188191
) -> set[tuple[str, int]]:
189-
"""Check if accepted licences satisfy required ones.
192+
"""
193+
Verify if all the licences required for the process submission have been accepted.
190194
191195
Parameters
192196
----------
193-
required_licences : set[tuple[str, int]]
194-
Required licences.
195-
accepted_licences : set[tuple[str, int]]
196-
Accepted licences.
197+
accepted_licences : set[tuple[str, int]] | list[tuple[str, int]],
198+
Licences accepted by a user stored in the Extended Profiles database.
199+
required_licences : set[tuple[str, int]] | list[tuple[str, int]],
200+
Licences bound to the required process/dataset.
201+
api_request_url : str
202+
API request URL, required to generate the URL to the dataset licences page.
203+
process_id : str
204+
Process identifier, required to generate the URL to the dataset licences page.
197205
198206
Returns
199207
-------
@@ -205,39 +213,30 @@ def check_licences(
205213
exceptions.PermissionDenied
206214
Raised if not all required licences have been accepted.
207215
"""
216+
if not isinstance(accepted_licences, set):
217+
accepted_licences = set(accepted_licences)
218+
if not isinstance(required_licences, set):
219+
required_licences = set(required_licences)
208220
missing_licences = required_licences - accepted_licences
209221
if not len(missing_licences) == 0:
210-
missing_licences_detail = [
211-
{"id": licence[0], "revision": licence[1]} for licence in missing_licences
212-
]
222+
missing_licences_message_template = (
223+
config.ensure_settings().missing_licences_message
224+
)
225+
dataset_licences_url_template = config.ensure_settings().dataset_licences_url
226+
parsed_api_request_url = urllib.parse.urlparse(api_request_url)
227+
base_url = f"{parsed_api_request_url.scheme}://{parsed_api_request_url.netloc}"
228+
dataset_licences_url = dataset_licences_url_template.format(
229+
base_url=base_url, process_id=process_id
230+
)
231+
missing_licences_message = missing_licences_message_template.format(
232+
dataset_licences_url=dataset_licences_url
233+
)
213234
raise exceptions.PermissionDenied(
214-
title="required licences not accepted",
215-
detail=(
216-
"required licences not accepted; "
217-
"please accept the following licences to proceed: "
218-
f"{missing_licences_detail}"
219-
),
235+
title="required licences not accepted", detail=missing_licences_message
220236
)
221237
return missing_licences
222238

223239

224-
def validate_licences(
225-
accepted_licences: set[tuple[str, str]],
226-
licences: list[tuple[str, int]],
227-
) -> None:
228-
"""Validate process execution request's payload in terms of required licences.
229-
230-
Parameters
231-
----------
232-
stored_accepted_licences : set[tuple[str, str]]
233-
Licences accepted by a user stored in the Extended Profiles database.
234-
licences : list[tuple[str, int]]
235-
Licences bound to the required process/dataset.
236-
"""
237-
required_licences = set(licences)
238-
check_licences(required_licences, accepted_licences) # type: ignore
239-
240-
241240
def verify_if_disabled(disabled_reason: str | None, user_role: str | None) -> None:
242241
"""Verify if a dataset's disabling reason grant access to the dataset for a specific user role.
243242
@@ -262,7 +261,7 @@ def verify_if_disabled(disabled_reason: str | None, user_role: str | None) -> No
262261

263262

264263
def verify_cost(
265-
request: dict[str, Any], adaptor_properties: dict[str, Any]
264+
request: dict[str, Any], adaptor_properties: dict[str, Any], request_origin: str
266265
) -> dict[str, float] | None:
267266
"""Verify if the cost of a process execution request is within the allowed limits.
268267
@@ -272,6 +271,8 @@ def verify_cost(
272271
Process execution request.
273272
adaptor_properties : dict[str, Any]
274273
Adaptor properties.
274+
request_origin : str
275+
Origin of the request. Can be either "api" or "ui".
275276
276277
Raises
277278
------
@@ -283,9 +284,13 @@ def verify_cost(
283284
dict[str, float] | None
284285
Request costs.
285286
"""
286-
costing_info = costing.compute_costing(request, adaptor_properties)
287-
max_costs_exceeded = costing_info.max_costs_exceeded
288-
if max_costs_exceeded:
287+
costing_info: models.CostingInfo = costing.compute_costing(
288+
request, adaptor_properties, request_origin
289+
)
290+
highest_cost: models.RequestCost = costing.compute_highest_cost_limit_ratio(
291+
costing_info
292+
)
293+
if highest_cost.cost > highest_cost.limit:
289294
raise exceptions.PermissionDenied(
290295
title="cost limits exceeded",
291296
detail="Your request is too large, please reduce your selection.",

cads_processing_api_service/clients.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ class DatabaseClient(ogc_api_processes_fastapi.clients.BaseClient):
7070
process_data_table = cads_catalogue.database.ResourceData
7171
job_table = cads_broker.database.SystemRequest
7272

73+
endpoints_description = {
74+
"GetProcesses": "Get the list of available processes' summaries.",
75+
"GetProcess": "Get the description of the process identified by `process_id`.",
76+
"PostProcessExecution": "Request execution of a process.",
77+
"GetJobs": "Get the list of submitted jobs, alongside information on their status.",
78+
"GetJob": "Get status information for the job identifed by `job_id`.",
79+
"GetJobResults": "Get results for the job identifed by `job_id`.",
80+
"DeleteJob": "Dismiss the job identifed by `job_id`.",
81+
}
82+
7383
def get_processes(
7484
self,
7585
limit: int | None = fastapi.Query(10, ge=1, le=10000),
@@ -191,6 +201,8 @@ def post_process_execution(
191201
192202
Parameters
193203
----------
204+
api_request: fastapi.Request
205+
API Request object.
194206
process_id : str
195207
Process identifier.
196208
execution_content : models.Execute
@@ -204,6 +216,7 @@ def post_process_execution(
204216
Submitted job's status information.
205217
"""
206218
user_uid, user_role = auth.authenticate_user(auth_header, portal_header)
219+
request_origin = auth.REQUEST_ORIGIN[auth_header[0]]
207220
structlog.contextvars.bind_contextvars(user_uid=user_uid)
208221
request = execution_content.model_dump()
209222
catalogue_sessionmaker = db_utils.get_catalogue_sessionmaker(
@@ -231,16 +244,22 @@ def post_process_execution(
231244
cads_adaptors.exceptions.InvalidRequest,
232245
) as exc:
233246
raise exceptions.InvalidRequest(detail=str(exc)) from exc
234-
costs = auth.verify_cost(request_inputs, adaptor_properties)
235-
licences = adaptor.get_licences(request_inputs)
247+
costs = auth.verify_cost(request_inputs, adaptor_properties, request_origin)
248+
required_licences = adaptor.get_licences(request_inputs)
236249
if user_uid != "anonymous":
237250
accepted_licences = auth.get_accepted_licences(auth_header)
238-
auth.validate_licences(accepted_licences, licences)
251+
api_request_url = str(api_request.url)
252+
_ = auth.verify_licences(
253+
accepted_licences, required_licences, api_request_url, process_id
254+
)
239255
job_message = None
240256
else:
241257
job_message = config.ensure_settings().anonymous_licences_message.format(
242258
licences="; ".join(
243-
[f"{licence[0]} (rev: {licence[1]})" for licence in licences]
259+
[
260+
f"{licence[0]} (rev: {licence[1]})"
261+
for licence in required_licences
262+
]
244263
)
245264
)
246265
job_id = str(uuid.uuid4())
@@ -255,7 +274,7 @@ def post_process_execution(
255274
job = cads_broker.database.create_request(
256275
session=compute_session,
257276
request_uid=job_id,
258-
origin=auth.REQUEST_ORIGIN[auth_header[0]],
277+
origin=request_origin,
259278
user_uid=user_uid,
260279
process_id=process_id,
261280
portal=dataset.portal,

cads_processing_api_service/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@
5151
"If you are using cdsapi, please upgrade to the latest version."
5252
)
5353

54+
55+
MISSING_LICENCES_MESSAGE = (
56+
"Not all the required licences have been accepted; "
57+
"please visit {dataset_licences_url} "
58+
"to accept the required licence(s)."
59+
)
60+
61+
5462
general_settings = None
5563

5664

@@ -76,6 +84,10 @@ class Settings(pydantic_settings.BaseSettings):
7684
missing_dataset_title: str = "Dataset not available"
7785
anonymous_licences_message: str = ANONYMOUS_LICENCES_MESSAGE
7886
deprecation_warning_message: str = DEPRECATION_WARNING_MESSAGE
87+
missing_licences_message: str = MISSING_LICENCES_MESSAGE
88+
dataset_licences_url: str = (
89+
"{base_url}/datasets/{process_id}?tab=download#manage-licences"
90+
)
7991

8092
download_nodes_config: str = "/etc/retrieve-api/download-nodes.config"
8193

cads_processing_api_service/costing.py

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License
1616

17+
import enum
1718
from typing import Any
1819

1920
import cads_adaptors
@@ -22,11 +23,34 @@
2223

2324
from . import adaptors, costing, db_utils, models, utils
2425

26+
COST_THRESHOLDS = {"api": "max_costs", "ui": "max_costs_portal"}
2527

26-
def estimate_costs(
28+
29+
class RequestOrigin(str, enum.Enum):
30+
api: str = "api"
31+
ui: str = "ui"
32+
33+
34+
def estimate_cost(
2735
process_id: str = fastapi.Path(...),
36+
request_origin: RequestOrigin = fastapi.Query("api"),
2837
execution_content: models.Execute = fastapi.Body(...),
29-
) -> models.Costing:
38+
) -> models.RequestCost:
39+
"""
40+
Estimate the cost with the highest cost/limit ratio of the request.
41+
42+
Parameters
43+
----------
44+
process_id : str
45+
Process ID.
46+
execution_content : models.Execute
47+
Request content.
48+
49+
Returns
50+
-------
51+
models.RequestCost
52+
Info on the cost with the highest cost/limit ratio.
53+
"""
3054
request = execution_content.model_dump()
3155
table = cads_catalogue.database.Resource
3256
catalogue_sessionmaker = db_utils.get_catalogue_sessionmaker(
@@ -38,28 +62,71 @@ def estimate_costs(
3862
)
3963
adaptor_properties = adaptors.get_adaptor_properties(dataset)
4064
costing_info = costing.compute_costing(
41-
request.get("inputs", {}), adaptor_properties
65+
request.get("inputs", {}), adaptor_properties, request_origin
4266
)
43-
return costing_info
67+
cost = costing.compute_highest_cost_limit_ratio(costing_info)
68+
return cost
69+
70+
71+
def compute_highest_cost_limit_ratio(
72+
costing_info: models.CostingInfo,
73+
) -> models.RequestCost:
74+
"""
75+
Compute the highest cost/limit ratio of the request.
76+
77+
Parameters
78+
----------
79+
costing_info : models.CostingInfo
80+
Costs of the request.
81+
82+
Returns
83+
-------
84+
models.RequestCost
85+
Info on the cost with the highest cost/limit ratio.
86+
"""
87+
costs = costing_info.costs
88+
limits = costing_info.limits
89+
highest_cost_limit_ratio = 0.0
90+
highest_cost = models.RequestCost()
91+
for limit_id, limit in limits.items():
92+
cost = costs.get(limit_id, 0.0)
93+
cost_limit_ratio = cost / limit if limit > 0 else 1.1
94+
if cost_limit_ratio > highest_cost_limit_ratio:
95+
highest_cost_limit_ratio = cost_limit_ratio
96+
highest_cost = models.RequestCost(cost=cost, limit=limit, id=limit_id)
97+
return highest_cost
4498

4599

46100
def compute_costing(
47101
request: dict[str, Any],
48102
adaptor_properties: dict[str, Any],
49-
) -> models.Costing:
103+
request_origin: str,
104+
) -> models.CostingInfo:
105+
"""
106+
Compute the costs of the request.
107+
108+
Parameters
109+
----------
110+
request : dict[str, Any]
111+
Request to be processed.
112+
adaptor_properties : dict[str, Any]
113+
Adaptor properties.
114+
115+
Returns
116+
-------
117+
models.CostingInfo
118+
Costs of the request.
119+
"""
50120
adaptor: cads_adaptors.AbstractAdaptor = adaptors.instantiate_adaptor(
51121
adaptor_properties=adaptor_properties
52122
)
53-
costs: dict[str, float] = adaptor.estimate_costs(request=request)
54-
costing_config: dict[str, Any] = adaptor_properties["config"].get("costing", {})
55-
max_costs: dict[str, Any] = costing_config.get("max_costs", {})
56-
max_costs_exceeded = {}
57-
for max_cost_id, max_cost_value in max_costs.items():
58-
max_cost_value = float(max_cost_value)
59-
if max_cost_id in costs.keys():
60-
if costs[max_cost_id] > max_cost_value:
61-
max_costs_exceeded[max_cost_id] = max_cost_value
62-
costing_info = models.Costing(
63-
costs=costs, max_costs=max_costs, max_costs_exceeded=max_costs_exceeded
123+
if request_origin not in COST_THRESHOLDS:
124+
raise ValueError(f"Invalid request origin: {request_origin}")
125+
cost_threshold = COST_THRESHOLDS[request_origin]
126+
costs: dict[str, float] = adaptor.estimate_costs(
127+
request=request, cost_threshold=cost_threshold
64128
)
129+
costing_config: dict[str, Any] = adaptor_properties["config"].get("costing", {})
130+
limits: dict[str, Any] = costing_config.get("max_costs", {})
131+
costing_info = models.CostingInfo(costs=costs, limits=limits)
65132
return costing_info

cads_processing_api_service/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,19 @@ async def lifespan(application: fastapi.FastAPI) -> AsyncGenerator[Any, None]:
6565
app.router.add_api_route(
6666
"/processes/{process_id}/constraints",
6767
constraints.apply_constraints,
68+
description="Apply constraints to the submitted process execution.",
6869
methods=["POST"],
6970
)
7071
app.router.add_api_route(
7172
"/processes/{process_id}/costing",
72-
costing.estimate_costs,
73+
costing.estimate_cost,
74+
description="Estimate costs of the submitted process execution.",
7375
methods=["POST"],
7476
)
7577
app.router.add_api_route(
7678
"/processes/{process_id}/api-request",
7779
translators.get_api_request,
80+
description="Get API request equivalent to the submitted prrocess execution json.",
7881
methods=["POST"],
7982
)
8083

0 commit comments

Comments
 (0)