1+ import json
12import re
23from typing import List
4+
5+ import requests
36from app .auth import exchange_token , get_current_user_claims
47from fastapi import Response
58from loguru import logger
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