Skip to content

Commit dcf782c

Browse files
authored
Merge pull request #69 from ESA-APEx/ogc_result_api
OGC API: Integration of result fetching
2 parents 99f10fe + 9b40893 commit dcf782c

1 file changed

Lines changed: 223 additions & 25 deletions

File tree

app/platforms/implementations/ogc_api_process.py

Lines changed: 223 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import json
12
import re
23
from typing import List
4+
5+
import requests
36
from app.auth import exchange_token, get_current_user_claims
47
from fastapi import Response
58
from loguru import logger
@@ -27,8 +30,10 @@
2730
"https://schemas.stacspec.org/v1.0.0/collection-spec/json-schema/collection.json"
2831
)
2932

30-
GEOJSON_FEATURECOLLECTION_SCHEMA = "https://schemas.opengis.net/ogcapi/" \
33+
GEOJSON_FEATURECOLLECTION_SCHEMA = (
34+
"https://schemas.opengis.net/ogcapi/"
3135
"features/part1/1.0/openapi/schemas/featureCollectionGeoJSON.yaml"
36+
)
3237

3338

3439
@register_platform(ProcessTypeEnum.OGC_API_PROCESS)
@@ -292,6 +297,206 @@ def _map_ogcapi_status(self, ogcapi_status: StatusCode) -> ProcessingStatusEnum:
292297
logger.warning(f"Mapping of unknown OGC API status: {ogcapi_status}")
293298
return ProcessingStatusEnum.UNKNOWN
294299

300+
def _extract_download_link_from_asset(self, asset: dict) -> str | None:
301+
"""
302+
Extracts the download link from an asset dictionary. Checks if the
303+
`href` field is present and contains an HTTPS URL. If this is not
304+
the case, look for an alternative link in `alternate`
305+
field. If no valid link is found, return None.
306+
307+
Args:
308+
asset (dict): The asset dictionary.
309+
310+
Returns:
311+
str | None: The download link if available, otherwise None.
312+
"""
313+
refs = [
314+
asset.get("href"),
315+
asset.get("alternate", {}).get("https", {}).get("href"),
316+
]
317+
for href in refs:
318+
if href and href.startswith("https://"):
319+
return href
320+
return None
321+
322+
def _generate_signed_url(self, href: str, user_token: str) -> str:
323+
"""
324+
Generate a signed URL for the given href using the provided user token.
325+
This is a placeholder implementation and should be replaced with
326+
actual logic to generate signed URLs.
327+
328+
Args:
329+
href (str): The original href.
330+
user_token (str): The user token to be used for signing.
331+
"""
332+
# TODO - Add implementation
333+
logger.debug(f"Generating signed URL for href: {href} with user token.")
334+
response = requests.get(
335+
href,
336+
headers={"Authorization": f"Bearer {user_token}"},
337+
allow_redirects=False,
338+
)
339+
signed_url = response.headers["location"]
340+
logger.debug(f"Signed URL: {signed_url}")
341+
return signed_url
342+
343+
def _update_assets_hrefs(self, assets: dict, user_token: str) -> dict:
344+
"""
345+
Update the hrefs of the assets to be HTTPS URLs. If the current
346+
href is an S3 URL, the code will look into `alternate` links to
347+
find an HTTPS URL. If no HTTPS URL is found, the original href
348+
will be kept.
349+
"""
350+
updated_assets = {}
351+
for asset_name, asset in assets.items():
352+
updated_asset = asset.copy()
353+
href = self._extract_download_link_from_asset(asset)
354+
if not href:
355+
logger.warning(
356+
"No valid HTTPS download link found for asset "
357+
f"'{asset_name}'. Keeping original href. "
358+
"Skipping asset..."
359+
)
360+
else:
361+
href = self._generate_signed_url(href, user_token)
362+
updated_asset["href"] = href
363+
updated_assets[asset_name] = updated_asset
364+
365+
return updated_assets
366+
367+
def _build_collection_from_features(
368+
self,
369+
features: list,
370+
assets: dict,
371+
result_name: str,
372+
user_token: str,
373+
details: ServiceDetails,
374+
internal_job_id: str,
375+
) -> Collection:
376+
"""
377+
Build a STAC Collection from a list of GeoJSON features and their
378+
aggregated assets. The spatial extent is derived from the feature
379+
bounding boxes and the temporal extent from the feature datetime
380+
properties.
381+
382+
Args:
383+
features: GeoJSON feature list.
384+
assets: Aggregated asset dict collected from those features.
385+
result_name: Identifier used as the collection ID.
386+
user_token: Token used to sign asset hrefs.
387+
details: Service details containing namespace and application information.
388+
internal_job_id: Internal job identifier.
389+
390+
Returns:
391+
A STAC Collection.
392+
"""
393+
# Spatial extent — union of per-feature bboxes
394+
min_x, min_y = float("inf"), float("inf")
395+
max_x, max_y = float("-inf"), float("-inf")
396+
found_bbox = False
397+
for feature in features:
398+
bbox = feature.get("bbox")
399+
if isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
400+
min_x = min(min_x, float(bbox[0]))
401+
min_y = min(min_y, float(bbox[1]))
402+
max_x = max(max_x, float(bbox[2]))
403+
max_y = max(max_y, float(bbox[3]))
404+
found_bbox = True
405+
spatial_bbox: tuple[float, float, float, float] = (
406+
(min_x, min_y, max_x, max_y) if found_bbox else (-180.0, -90.0, 180.0, 90.0)
407+
)
408+
409+
# Temporal extent — min/max of all datetime-like properties
410+
datetimes: list[str] = []
411+
for feature in features:
412+
props = feature.get("properties") or {}
413+
for dt_key in ("datetime", "start_datetime", "end_datetime"):
414+
dt_val = props.get(dt_key)
415+
if isinstance(dt_val, str):
416+
datetimes.append(dt_val)
417+
temporal_interval: list[list] = (
418+
[[min(datetimes), max(datetimes)]] if datetimes else [[None, None]]
419+
)
420+
421+
updated_assets = self._update_assets_hrefs(assets, user_token)
422+
423+
logger.debug(
424+
f"Building STAC Collection '{result_name}' from {len(features)} feature(s) "
425+
f"with {len(updated_assets)} asset(s)."
426+
)
427+
428+
return Collection(
429+
id=f"{details.namespace}-{internal_job_id}",
430+
stac_version=STAC_VERSION,
431+
title=f"Results for {details.application}",
432+
description=(
433+
f"OGC API process result items for job '{internal_job_id}' "
434+
f"of application '{details.application}'."
435+
),
436+
type="Collection",
437+
license="proprietary",
438+
links=Links([]),
439+
extent=Extent(
440+
spatial=SpatialExtent(bbox=[spatial_bbox]),
441+
temporal=TimeInterval(interval=temporal_interval),
442+
),
443+
assets=updated_assets,
444+
)
445+
446+
def _extract_assets_from_feature_collection(
447+
self,
448+
feature_collection: dict,
449+
*,
450+
result_name: str,
451+
user_token: str,
452+
details: ServiceDetails,
453+
internal_job_id: str,
454+
) -> Collection:
455+
assets: dict = {}
456+
features = feature_collection.get("features", [])
457+
logger.debug(f"Feature collection: {json.dumps(feature_collection, indent=2)}")
458+
for feature in features:
459+
feature_assets = feature.get("assets")
460+
if isinstance(feature_assets, dict):
461+
assets.update(feature_assets)
462+
continue
463+
464+
# Some providers expose assets through an item link
465+
# instead of inlining them in the feature.
466+
for link in feature.get("links", []):
467+
if "collection" == link.get("rel") and link.get("href"):
468+
collection_link: str = link.get("href")
469+
logger.debug(
470+
f"GeoJSON FeatureCollection results: '{result_name}' "
471+
f"points to a valid collection URL: {collection_link}"
472+
)
473+
474+
response: HTTPXResponse = http_get(
475+
collection_link,
476+
follow_redirects=True,
477+
headers={"Authorization": f"Bearer {user_token}"},
478+
)
479+
response.raise_for_status()
480+
collection_data = response.json()
481+
collection_data["assets"] = self._update_assets_hrefs(
482+
collection_data.get("assets", {}), user_token
483+
)
484+
collection = Collection.model_validate(collection_data)
485+
logger.debug(
486+
f"Extracted collection '{collection.id}' "
487+
f"with assets: {list((collection.assets or {}).keys())}"
488+
)
489+
return collection
490+
491+
return self._build_collection_from_features(
492+
features,
493+
assets,
494+
result_name,
495+
user_token,
496+
details,
497+
internal_job_id
498+
)
499+
295500
async def get_job_status(
296501
self, user_token: str, job_id: str, details: ServiceDetails
297502
) -> ProcessingStatusEnum:
@@ -352,17 +557,18 @@ async def get_job_results(
352557
and qualified_value.var_schema.actual_instance
353558
):
354559
schema_reference = qualified_value.var_schema.actual_instance
560+
media_type = getattr(qualified_value, "media_type", None)
355561
logger.debug(
356562
f"Processing result\n* Name: '{result_name}'\n"
357-
"* media type: {qualified_value.media_type}\n"
358-
"* Python type: {type(qualified_value.value)}\n"
359-
"* schema {qualified_value.var_schema}..."
563+
f"* media type: {media_type}\n"
564+
f"* Python type: {type(qualified_value.value)}\n"
565+
f"* schema {qualified_value.var_schema}..."
360566
)
361567

362568
if not isinstance(schema_reference, str):
363569
logger.warning(
364570
f"Processing result name: '{result_name}' can not be processed, "
365-
"schema of type {type(schema_reference)} not recognized"
571+
f"schema of type {type(schema_reference)} not recognized"
366572
)
367573
continue
368574

@@ -375,29 +581,20 @@ async def get_job_results(
375581
logger.success(
376582
f"GeoJSON FeatureCollection found in results: '{result_name}'"
377583
)
378-
feature_collection = qualified_value.value.oneof_schema_2_validator or {}
379-
for feature in feature_collection.get("features", []):
380-
for link in feature.get("links", []):
381-
if "collection" == link.get("rel") and link.get("href"):
382-
collection_link: str = link.get("href")
383-
logger.success(
384-
f"GeoJSON FeatureCollection results: '{result_name}' "
385-
"points to a valid collection URL: {collection_link}"
386-
)
387-
388-
response: HTTPXResponse = http_get(
389-
collection_link,
390-
follow_redirects=True,
391-
headers={
392-
"Authorization": f"Bearer {exchanged_token}"
393-
},
394-
)
395-
response.raise_for_status()
396-
return Collection.model_validate(response.json())
584+
feature_collection = (
585+
qualified_value.value.oneof_schema_2_validator or {}
586+
)
587+
return self._extract_assets_from_feature_collection(
588+
feature_collection,
589+
result_name=result_name,
590+
user_token=exchanged_token or user_token,
591+
details=details,
592+
internal_job_id=internal_job_id,
593+
)
397594
else:
398595
logger.warning(
399596
f"Processing result: '{result_name}' can not be processed, "
400-
"schema {schema_reference} not yet managed"
597+
f"schema {schema_reference} not yet managed"
401598
)
402599

403600
# result not found, send back an empty collection
@@ -417,6 +614,7 @@ async def get_job_results(
417614
spatial=SpatialExtent(bbox=[(-180.0, -90.0, 180.0, 90.0)]),
418615
temporal=TimeInterval(interval=[[None, None]]),
419616
),
617+
assets={},
420618
)
421619

422620
async def get_service_parameters(

0 commit comments

Comments
 (0)