app/resto/core/addons/STAC.php

Summary

Maintainability
F
3 days
Test Coverage
<?php
/*
 * Copyright 2022 Jérôme Gasperi
 *
 * Licensed under the Apache License, version 2.0 (the "License");
 * You may not use this file except in compliance with the License.
 * You may obtain a copy of the License at:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

/**
 * STAC add-on
 * 
 *  @OA\Tag(
 *      name="Catalog",
 *      description="A STAC Catalog is a collection of STAC Items"
 *  )
 * 
 *  @OA\Schema(
 *      schema="Catalog",
 *      required={"id", "description", "links", "stac_version"},
 *      @OA\Property(
 *          property="id",
 *          type="string",
 *          description="Identifier for the catalog."
 *      ),
 *      @OA\Property(
 *          property="title",
 *          type="string",
 *          description="A short descriptive one-line title for the catalog."
 *      ),
 *      @OA\Property(
 *          property="description",
 *          type="string",
 *          description="Detailed multi-line description to fully explain the catalog. CommonMark 0.28 syntax MAY be used for rich text representation."
 *      ),
 *      @OA\Property(
 *          property="links",
 *          type="array",
 *          @OA\Items(ref="#/components/schemas/Link")
 *      ),
 *      @OA\Property(
 *          property="stac_version",
 *          type="string",
 *          description="The STAC version the catalog implements"
 *      ),
 *      example={
 *          "id": "year",
 *          "title": "Facet : year",
 *          "description": "Catalog of items filtered by year",
 *          "links": {
 *              {
 *                  "rel": "self",
 *                  "type": "application/json",
 *                  "href": "http://127.0.0.1:5252/collections/S2.json?&_pretty=1"
 *              },
 *              {
 *                  "rel": "root",
 *                  "type": "application/json",
 *                  "href": "http://127.0.0.1:5252"
 *              },
 *              {
 *                  "rel": "license",
 *                  "href": "https://scihub.copernicus.eu/twiki/pub/SciHubWebPortal/TermsConditions/Sentinel_Data_Terms_and_Conditions.pdf",
 *                  "title": "Legal notice on the use of Copernicus Sentinel Data and Service Information"
 *              }
 *          },
 *          "stac_version": "1.0.0"
 *      }
 *  )
 * 
 *  @OA\Schema(
 *      schema="Queryables",
 *      @OA\Property(
 *          property="$schema",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="$id",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="type",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="title",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="description",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="properties",
 *          type="object",
 *          @OA\JsonContent()
 *      ),
 *      @OA\Property(
 *          property="additionalProperties",
 *          type="boolean"
 *      )
 *  )
 */
class STAC extends RestoAddOn
{
    /**
     * Links
     *
     * @OA\Schema(
     *      schema="Link",
     *      description="Link",
     *      required={"rel", "href"},
     *      @OA\Property(
     *          property="rel",
     *          type="string",
     *          description="Relationship between the feature and the linked document/resource"
     *      ),
     *      @OA\Property(
     *          property="type",
     *          type="string",
     *          description="Mimetype of the resource"
     *      ),
     *      @OA\Property(
     *          property="title",
     *          type="string",
     *          description="Title of the resource"
     *      ),
     *      @OA\Property(
     *          property="href",
     *          type="string",
     *          description="Url to the resource"
     *      ),
     *      example={
     *          "rel": "self",
     *          "type": "application/json",
     *          "href": "http://127.0.0.1:5252/collections/S2.json?&_pretty=1"
     *      }
     * )
     *
     * Assets
     *
     * @OA\Schema(
     *      schema="Asset",
     *      description="Asset links",
     *      required={"rel", "href"},
     *      @OA\Property(
     *          property="rel",
     *          type="string",
     *          description="Relationship between the feature and the linked document/resource"
     *      ),
     *      @OA\Property(
     *          property="type",
     *          type="string",
     *          description="Mimetype of the resource"
     *      ),
     *      @OA\Property(
     *          property="title",
     *          type="string",
     *          description="Title of the resource"
     *      ),
     *      @OA\Property(
     *          property="href",
     *          type="string",
     *          description="Url to the resource"
     *      ),
     *      @OA\Property(
     *          property="roles",
     *          type="array",
     *          description="Asset roles",
     *          @OA\Items(
     *              type="string",
     *          )
     *      ),
     *      example={
     *          "href": "https://landsat-pds.s3.amazonaws.com/c1/L8/171/002/LC08_L1TP_171002_20200616_20200616_01_RT/LC08_L1TP_171002_20200616_20200616_01_RT_B1.TIF",
     *          "type": "image/tiff; application=geotiff; profile=cloud-optimized",
     *          "roles":{"data"},
     *          "eo:bands": {
     *              0
     *          }
     *      }
     * )
     */

    /*
     * STAC version
     */
    const STAC_VERSION = '1.0.0';

    /*
     * STAC namespaces
     */
    const CONFORMANCE_CLASSES = array(
        'https://api.stacspec.org/v1.0.0/core',
        'https://api.stacspec.org/v1.0.0/collections',
        'https://api.stacspec.org/v1.0.0/ogcapi-features',
        'https://api.stacspec.org/v1.0.0-rc.3/browseable',
        'https://api.stacspec.org/v1.0.0-rc.2/children',
        'https://api.stacspec.org/v1.0.0/item-search',
        'https://api.stacspec.org/v1.0.0/item-search#fields',
        // Unsupported
        //'https://api.stacspec.org/v1.0.0/item-search#query',
        'https://api.stacspec.org/v1.0.0/item-search#sort',
        'https://api.stacspec.org/v1.0.0-rc.3/item-search#filter',

        'http://www.opengis.net/spec/ogcapi_common-2/1.0/conf/collections',

        'https://api.stacspec.org/v1.0.0/ogcapi-features#fields',
        'https://api.stacspec.org/v1.0.0/ogcapi-features#sort',
        'https://api.stacspec.org/v1.0.0/ogcapi-features#filter',

        'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core',
        'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson',
        'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30',
        'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html',
        'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter',
        'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter',

        'http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2',
        'http://www.opengis.net/spec/cql2/1.0/conf/cql2-text',
        'http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators'
    );

    /*
     * Catalog title
     */
    public $title;

    /*
     * Catalog description
     */
    public $description = 'Available catalogs';

    /*
     * Links
     */
    public $links = array();

    /*
     * FeatureCollection
     */
    public $featureCollection = null;

    /*
     * STAC Util
     */
    private $stacUtil = null;

    /*
     * Url segments
     */
    private $segments = array();

    /**
     * Constructor
     *
     * @param RestoContext $context
     * @param RestoUser $user
     */
    public function __construct($context, $user)
    {
        parent::__construct($context, $user);
        $this->stacUtil = new STACUtil($context, $user);

        // Ensure valid options
        $this->options['minMatch'] = isset($this->options['minMatch']) && is_int($this->options['minMatch']) ? $this->options['minMatch'] : 0;
    }

    /**
     * 
     * Return an asset href within an HTTP 301 Redirect message
     * Get asset from this endpoint allows to store download external asset statistics
     * 
     *    @OA\Get(
     *      path="/assets/{urlInBase64}",
     *      summary="Download asset",
     *      description="Return the asset href within an HTTP 301 Redirect message. This allows to keep track of download of external assets in resto statistics",
     *      tags={"STAC"},
     *      @OA\Parameter(
     *         name="urlInBase64",
     *         in="path",
     *         required=true,
     *         description="Asset url encoded in Base64",
     *         @OA\Schema(
     *             type="string"
     *         )
     *      ),
     *      @OA\Response(
     *          response="301",
     *          description="HTTP/1.1 301 Moved Permanently"
     *      ),
     *      @OA\Response(
     *          response="400",
     *          description="Invalid base64 encoded url",
     *      )
     *    )
     *
     * @param array $params
     */
    public function getAsset($params)
    {
        $url = base64_decode($params['urlInBase64']);

        /*
         * Should be a valid url
         */
        if (!$url || strpos($url, 'http') !== 0) {
            RestoLogUtil::httpError(400, 'Invalid base64 encoded url');
        }

        /*
         * Store download in logs
         */
        try {
            (new GeneralFunctions($this->context->dbDriver))->storeQuery($this->user && $this->user->profile ? $this->user->profile['id'] : null, array(
                'path' => $url,
                'method' => 'GET_ASSET'
            ));
        } catch (Exception $e) {
            error_log('[WARNING] Cannot store download info in resto.log');
        }

        /*
         * Permanent 301 redirection
         */
        header('HTTP/1.1 301 Moved Permanently');
        header('Location: ' . $url);

        return;
    }

    /**
     * Return a STAC catalog from facet
     *
     *    @OA\Get(
     *      path="/catalogs/*",
     *      summary="Get STAC catalogs",
     *      description="Get STAC catalogs",
     *      tags={"STAC"},
     *      @OA\Response(
     *          response="200",
     *          description="STAC catalog definition - contains links to child catalogs and/or items",
     *          @OA\JsonContent(
     *              ref="#/components/schemas/Catalog"
     *          )
     *      ),
     *      @OA\Response(
     *          response="404",
     *          description="Not found"
     *      )
     *    )
     */
    public function getCatalogs($params)
    {

        // This is /catalogs
        if (!isset($params['segments'])) {
            return RestoLogUtil::httpError(404);
        }

        $this->segments = $params['segments'];

        $result = $this->load($params);
        
        return isset($result->featureCollection) ? $result->featureCollection : $result;
    }


    /**
     * Return the list of children catalog
     * (see https://github.com/radiantearth/stac-api-spec/tree/main/children)
     *
     *    @OA\Get(
     *      path="/children",
     *      summary="Get root child catalogs",
     *      description="List of children of this catalog",
     *      tags={"STAC"},
     *      @OA\Response(
     *          response="200",
     *          description="List of children of the root catalog",
     *          @OA\JsonContent(
     *              @OA\Property(
     *                  property="features",
     *                  type="array",
     *                  description="Array of features",
     *                  @OA\Items(ref="#/components/schemas/OutputFeature")
     *              )
     *          )
     *     )
     *    )
     */
    public function getChildren($params)
    {
        $childs = array();

        // Initialize router to process each children individually
        $router = new RestoRouter($this->context, $this->user);

        $links = $this->stacUtil->getRootCatalogLinks($this->options['minMatch']);
        for ($i = 0, $ii = count($links); $i < $ii; $i++) {
            if ($links[$i]['rel'] == 'child') {
                try {
                    $response = $router->process('GET', parse_url($links[$i]['href'])['path'], array());
                } catch (Exception $e) {
                    continue;
                }
                if (isset($response)) {
                    $childs[] = $response->toArray();
                }
            }
        }

        return array(
            'children' => $childs,
            'links' => array(
                array(
                    'rel' => 'self',
                    'type' => RestoUtil::$contentTypes['json'],
                    'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_CHILDREN
                ),
                array(
                    'rel' => 'root',
                    'type' => RestoUtil::$contentTypes['json'],
                    'href' => $this->context->core['baseUrl']
                )
            )
        );
    }

    /**
     * Return the list of queryables
     * (see https://github.com/stac-api-extensions/filter?tab=readme-ov-file#queryables)
     *
     *    @OA\Get(
     *      path="/queryables",
     *      summary="Queryables for STAC API",
     *      description="Queryable names for the STAC API Item Search filter.",
     *      tags={"STAC"},
     *      @OA\Response(
     *          response="200",
     *          description="Queryables for STAC API",
     *          @OA\JsonContent(
     *              ref="#/components/schemas/Queryables"
     *          )
     *     )
     *    )
     */
    public function getQueryables($params)
    {
        // [IMPORTANT] Supersede output format
        $this->context->outputFormat = 'jsonschema';

        return array(
            '$schema' => 'https://json-schema.org/draft/2019-09/schema',
            '$id' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_QUERYABLES,
            'type' => 'object',
            'title' => 'Queryables for Example STAC API',
            'description' => 'Queryable names for the example STAC API Item Search filter.',
            // Get common queryables (/queryables) or per collection (/collections/{collectionId}/queryables)
            'properties' => (isset($params['collectionId']) ? ($this->context->keeper->getRestoCollection($params['collectionId'], $this->user)->load())->model : new DefaultModel())->getQueryables(),
            'additionalProperties' => true
        );
    }

    /**
     * Search for features in all collections
     *
     *  @OA\Get(
     *      path="/search",
     *      summary="STAC search endpoint",
     *      description="List of filters to search features within all collections",
     *      tags={"Feature"},
     *      @OA\Parameter(
     *          name="model",
     *          in="query",
     *          description="Search features within collections belonging to *model* - e.g. *model=SatelliteModel* will search in all satellite collections",
     *          required=false,
     *          style="form",
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="collections",
     *          in="query",
     *          style="form",
     *          description="Search features within collections - comma separated list of collection identifiers",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *         name="ck",
     *         in="query",
     *         style="form",
     *         required=false,
     *         description="Stands for *collection keyword* - limit results to collection containing the input keyword",
     *         @OA\Schema(
     *             type="string"
     *         )
     *      ),
     *      @OA\Parameter(
     *          name="q",
     *          in="query",
     *          style="form",
     *          description="Free text search - OpenSearch {searchTerms}. Can include hashtags i.e. text starting with *#* characters. In this case, use the following:
* *#cryosphere* will search for *cryosphere*
* *#cryosphere #atmosphere* will search for *cryosphere* AND *atmosphere*
* *#cryosphere|atmosphere* will search for *cryosphere* OR *atmosphere*
* *#cryosphere!* will search for *cryosphere* OR any *broader* concept of *cryosphere* ([EXTENSION][SKOS])
* *#cryosphere\** will search for *cryosphere* OR any *narrower* concept of *cryosphere* ([EXTENSION][SKOS])
* *#cryosphere~* will search for *cryosphere* OR any *related* concept of *cryosphere* ([EXTENSION][SKOS])",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="limit",
     *          in="query",
     *          style="form",
     *          description="Number of results returned per page - between 1 and 500 (default 20) - OpenSearch {count}",
     *          required=false,
     *          @OA\Schema(
     *              type="integer",
     *              minimum=1,
     *              maximum=500,
     *              default=20
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="startIndex",
     *          in="query",
     *          style="form",
     *          description="First result to provide - minimum 1, (default 1) - OpenSearch {startIndex}",
     *          required=false,
     *          @OA\Schema(
     *              type="integer",
     *              minimum=1,
     *              default=1
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="page",
     *          in="query",
     *          style="form",
     *          description="First page to provide - minimum 1, (default 1) - OpenSearch {startPage}",
     *          required=false,
     *          @OA\Schema(
     *              type="integer",
     *              minimum=1,
     *              default=1
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="lang",
     *          in="query",
     *          style="form",
     *          description="Two letters language code according to ISO 639-1 (default *en*) - OpenSearch {language}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="ids",
     *          in="query",
     *          style="form",
     *          description="Array of item ids to return. All other filter parameters that further restrict the number of search results (except next and limit) are ignored",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="intersects",
     *          in="query",
     *          style="form",
     *          description="Region of Interest defined in GeoJSON or in Well Known Text standard (WKT) with coordinates in decimal degrees (EPSG:4326) - OpenSearch {geo:geometry}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="bbox",
     *          in="query",
     *          style="form",
     *          description="Region of Interest defined by 'west, south, east, north' coordinates of longitude, latitude, in decimal degrees (EPSG:4326) - OpenSearch {geo:box}",
     *          required=false,
     *          @OA\Schema(
     *              type="array",
     *              minItems=4,
     *              maxItems=6,
     *              @OA\Items(
     *                  type="number",
     *              )
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="name",
     *          in="query",
     *          style="form",
     *          description="[EXTENSION][egg] Location string e.g. Paris, France  or toponym identifier (i.e. geouid:xxxx) - OpenSearch {geo:name}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="lon",
     *          in="query",
     *          style="form",
     *          description="Longitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lat - OpenSearch {geo:lon}",
     *          required=false,
     *          @OA\Schema(
     *              type="number"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="lat",
     *          in="query",
     *          style="form",
     *          description="Latitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lon - OpenSearch {geo:lat}",
     *          required=false,
     *          @OA\Schema(
     *              type="number"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="radius",
     *          in="query",
     *          style="form",
     *          description="Radius expressed in meters - should be used with geo:lon and geo:lat - OpenSearch {geo:radius}",
     *          required=false,
     *          @OA\Schema(
     *              type="number"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="datetime",
     *          in="query",
     *          style="form",
     *          description="Single date+time, or a range ('/' separator) of the search query. Format should follow RFC-3339 - OpenSearch {time:start}/{time:end}",
     *          required=false,
     *          @OA\Schema(
     *              type="string",
     *              format="date-time",
     *              pattern="^([0-9]{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?(([Zz])|([\+|\-]([01][0-9]|2[0-3]):[0-5][0-9]))$"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="start",
     *          in="query",
     *          style="form",
     *          description="Beginning of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:start}",
     *          required=false,
     *          @OA\Schema(
     *              type="string",
     *              format="date-time",
     *              pattern="^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(|Z|[\+\-][0-9]{2}:[0-9]{2}))?$"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="end",
     *          in="query",
     *          style="form",
     *          description="End of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:end}",
     *          required=false,
     *          @OA\Schema(
     *              type="string",
     *              format="date-time",
     *              pattern="^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(|Z|[\+\-][0-9]{2}:[0-9]{2}))?$"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="created",
     *          in="query",
     *          style="form",
     *          description="Returns products with metadata creation date greater or equal than *created* - OpenSearch {dc:date}",
     *          required=false,
     *          @OA\Schema(
     *              type="string",
     *              format="date-time",
     *              pattern="^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(|Z|[\+\-][0-9]{2}:[0-9]{2}))?$"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="prev",
     *          in="query",
     *          style="form",
     *          description="Returns features with *sort* key value greater than *prev* value - use this for pagination. The value is a unique iterator computed from the *sort* key value and provided within each feature properties as *sort_idx* property",
     *          required=false,
     *          @OA\Schema(
     *              type="integer"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="next",
     *          in="query",
     *          style="form",
     *          description="Returns features with *sort* key value lower than *next* value - use this for pagination. The value is a unique iterator computed from the *sort* key value and provided within each feature properties as *sort_idx* property",
     *          required=false,
     *          @OA\Schema(
     *              type="integer"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="pid",
     *          in="query",
     *          style="form",
     *          description="Like on product identifier",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="sort",
     *          in="query",
     *          style="form",
     *          description="Sort results by property *startDate* or *created* (default *startDate*). Sorting order is DESCENDING (ASCENDING if property is prefixed by minus sign)",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="owner",
     *          in="query",
     *          style="form",
     *          description="Limit search to owner's features",
     *          required=false,
     *          @OA\Schema(
     *              type="integer"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="likes",
     *          in="query",
     *          style="form",
     *          description="[EXTENSION][social] Limit search to number of likes (interval)",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="liked",
     *          in="query",
     *          style="form",
     *          description="[EXTENSION][social] Return only liked features from calling user",
     *          required=false,
     *          @OA\Schema(
     *              type="boolean"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="status",
     *          in="query",
     *          style="form",
     *          description="Feature status (unusued)",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="productType",
     *          in="query",
     *          style="form",
     *          description="[MODEL][SatelliteModel] A string identifying the entry type (e.g. ER02_SAR_IM__0P, MER_RR__1P, SM_SLC__1S, GES_DISC_AIRH3STD_V005) - OpenSearch {eo:productType}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="processingLevel",
     *          in="query",
     *          style="form",
     *          description="[MODEL][SatelliteModel] A string identifying the processing level applied to the entry - OpenSearch {eo:processingLevel}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="platform",
     *          in="query",
     *          style="form",
     *          description="[MODEL][SatelliteModel] A string with the platform short name (e.g. Sentinel-1) - OpenSearch {eo:platform}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="instrument",
     *          in="query",
     *          style="form",
     *          description="[MODEL][SatelliteModel] A string identifying the instrument (e.g. MERIS, AATSR, ASAR, HRVIR. SAR) - OpenSearch {eo:instrument}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="sensorType",
     *          in="query",
     *          style="form",
     *          description="[MODEL][SatelliteModel] A string identifying the sensor type. Suggested values are: OPTICAL, RADAR, ALTIMETRIC, ATMOSPHERIC, LIMB - OpenSearch {eo:sensorType}",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="cloudCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][OpticalModel] Cloud cover expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="snowCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][OpticalModel] Snow cover expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="waterCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Water area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="urbanCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Urban area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="iceCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Ice area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="herbaceousCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Herbaceous area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="forestCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Forest area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="floodedCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Flooded area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="desertCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Desert area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="cultivatedCover",
     *          in="query",
     *          style="form",
     *          description="[MODEL][LandCoverModel] Cultivated area expressed in percent",
     *          required=false,
     *          @OA\Schema(
     *              type="string"
     *          )
     *      ),
     *      @OA\Parameter(
     *          name="fields",
     *          in="query",
     *          style="form",
     *          description="Comma separated list of property fields to be returned",
     *          required=false,
     *          @OA\Items(
     *              type="string"
     *          ),
     *          description="Comma separated list of property fields to be returned. The following reserved keywords can also be used:
* _all: Return all properties (This is the default)
* _simple: Return all fields except *keywords* property"
     *      ),
     *      @OA\Parameter(
     *          name="_heatmapNoGeo",
     *          in="query",
     *          style="form",
     *          description="[EXTENSION][Heatmap] True to compute search result heatmap without taking account geographical filter",
     *          required=false,
     *          @OA\Schema(
     *              type="boolean"
     *          )
     *      ),
     *      @OA\Response(
     *          response="200",
     *          description="Features collection",
     *          @OA\JsonContent(ref="#/components/schemas/RestoFeatureCollection")
     *      ),
     *      @OA\Response(
     *          response="400",
     *          description="Bad request (i.e. invalid parameter)",
     *          @OA\JsonContent(ref="#/components/schemas/BadRequestError")
     *      ),
     *      @OA\Response(
     *          response="404",
     *          description="Collection not Found",
     *          @OA\JsonContent(ref="#/components/schemas/NotFoundError")
     *      )
     * )
     *
     * @param array params
     */
    public function search($params, $body)
    {
        if ($this->context->method === 'POST') {
            $params = $this->jsonQueryToKVP($body);
        }
        
        $model = null;
        if (isset($params['model'])) {
            if (! class_exists($params['model'])) {
                return RestoLogUtil::httpError(400, 'Unknown model ' . $params['model']);
            }
            $model = new $params['model']();
        }
        
        // [STAC] Only one of either intersects or bbox should be specified. If both are specified, a 400 Bad Request response should be returned.
        if (isset($params['intersects']) && isset($params['bbox'])) {
            return RestoLogUtil::httpError(400, 'Only one of either intersects or bbox should be specified');
        }

        // Set Content-Type to GeoJSON
        $this->context->outputFormat = 'geojson';

        /*
         * [TODO][CHANGE THIS] Temporary solution for collection that are not in resto schema
         *   => replace search on single collection by direct search on single collection
         */
        if (isset($params['collections'])) {
            $collections = array_map('trim', explode(',', $params['collections']));
            if (count($collections) === 1) {
                $params['collectionId'] = $params['collections'];
                unset($params['collections']);
                return $this->context->keeper->getRestoCollection($params['collectionId'], $this->user)->load()->search($params);
            }
        }

        $restoCollections = $this->context->keeper->getRestoCollections($this->user)->load($params);

        /* [TODO] Faire un UNION sur les collections
        if ( !isset($model) ) {
            $schemaNames = array();
            foreach (array_keys($restoCollections->collections) as $collectionId) {
                if ( !in_array($restoCollections->collections[$collectionId]->model->dbParams['tablePrefix'], $schemaNames) ) {
                    $schemaNames[] = $restoCollections->collections[$collectionId]-model->dbParams['tablePrefix'];
                }
            }
        }
        */

        return $restoCollections->search($model, $params);
    }

    /**
     * Output catalog description as an array
     *
     */
    public function toArray()
    {
        $nbOfSegments = count($this->segments);
        return array(
            'id' => $nbOfSegments > 0 ? array_slice($this->segments, -1)[0] : array('root'),
            'type' => 'Catalog',
            'title' => $this->title,
            'description' => $this->description,
            'links' => array_merge(
                array(
                    array(
                        'rel' => 'self',
                        'type' => RestoUtil::$contentTypes['json'],
                        'href' => $this->context->core['baseUrl'] . '/catalogs' . ($nbOfSegments > 0 ? '/' . join('/', array_map('rawurlencode', $this->segments)) : '')
                    ),
                    array(
                        'rel' => 'root',
                        'type' => RestoUtil::$contentTypes['json'],
                        'href' => $this->context->core['baseUrl']
                    )
                ),
                $this->links ?? array()
            ),
            'stac_version' => STAC::STAC_VERSION
        );
    }

    /**
     * Output collection description as a JSON stream
     *
     * @param boolean $pretty : true to return pretty print
     */
    public function toJSON($pretty = false)
    {
        return json_encode($this->toArray(), $pretty ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES);
    }


    /**
     * Load catalog from database
     * Return 404 if  is not found
     *
     * @param array $params
     * @return This object
     */
    private function load($params = array())
    {
        $nbOfSegments = count($this->segments);

        // Root
        if ($nbOfSegments === 0) {
            $this->links = $this->stacUtil->getRootCatalogLinks($this->options['minMatch']);
        }

        // View special case
        elseif ($this->segments[0] === 'views' && isset($this->context->addons['View'])) {
            $view = new View($this->context, $this->user);
            
            // Root
            if ($nbOfSegments === 1) {
                $viewCatalog = $view->getViews(array(
                    'format' => 'stac'
                ));
                $this->title = $viewCatalog['title'];
                $this->description = $viewCatalog['description'];
                $this->links = $viewCatalog['links'];
            }
            // Individual view
            elseif ($nbOfSegments === 2) {
                return $view->getView(array_merge($this->context->query, array(
                    'viewId' => $this->segments[1],
                    'format' => 'stac'
                )));
            }
            // Individual views
            else {
                return RestoLogUtil::httpError(404);
            }
        }

        // SOSA special case
        elseif ($this->segments[0] === 'concepts' && isset($this->context->addons['SOSA'])) {
            $skos = new SKOS($this->context, $this->user);
            
            // Root
            if ($nbOfSegments === 1) {
                return $skos->getConcepts($this->context->query);
            }
            // Individual concept
            elseif ($nbOfSegments === 2) {
                return $skos->getConcept(array_merge($this->context->query, array(
                    'conceptId' => $this->segments[1],
                )));
            }
            // Nothing found
            else {
                return RestoLogUtil::httpError(404);
            }
        }

        // Classifications
        elseif ($this->segments[0] === 'facets') {

            // Root
            if ($nbOfSegments === 1) {
                $this->setClassificationsLinks(null);
            }

            // iTag classifications 
            elseif ($nbOfSegments === 2 && in_array($this->segments[1], array_keys($this->stacUtil->classifications)) ) {
                $this->setClassificationsLinks($this->segments[1]);
            }

            // Otherwise compute links from facets
            else {
                $this->setCatalogsLinksFromFacet($params);
            }
        }

        // Hashtags
        elseif ($this->segments[0] === 'hashtags' || $this->segments[0] === 'catalogs') {
            $this->setCatalogsLinksFromFacet($params);
        }

        // Themes
        elseif ($this->segments[0] === 'themes') {
            // Root
            if ($nbOfSegments === 1) {
                $this->title = 'Themes';
                $this->description = 'List of collection per theme';
                $this->links = array_merge($this->links, $this->stacUtil->getThemesRootLinks());
            }

            // Two segments (except landcover)
            elseif ($nbOfSegments === 2) {
                $this->setThemesLinks();
            } else {
                return RestoLogUtil::httpError(404);
            }
        }

        // Not found
        else {
            return RestoLogUtil::httpError(404);
        }
        
        return $this;
    }

    /**
     * Set links array for a given theme
     *
     * @return array
     */
    private function setThemesLinks()
    {
        $this->title = $this->segments[1];
        $this->description = 'Collections for theme **' . $this->segments[1] . '**';

        // Load collections
        $collections = $this->context->keeper->getRestoCollections($this->user)->load();
        
        $candidates = array();
        foreach (array_values($collections->collections) as $collectionContent) {
            if (isset($collectionContent->keywords)) {
                for ($i = count($collectionContent->keywords); $i--;) {
                    $splitted = explode(':', $collectionContent->keywords[$i]);
                    if (count($splitted) > 1 && $splitted[0] === 'label' && $splitted[1] === $this->segments[1]) {
                        $collectionArray = $collectionContent->toArray();
                        $this->links[] = array(
                            'rel' => 'child',
                            'title' => $collectionArray['title'],
                            'description' => $collectionArray['description'],
                            'type' => RestoUtil::$contentTypes['json'],
                            'href' => $this->context->core['baseUrl'] . RestoUtil::replaceInTemplate(RestoRouter::ROUTE_TO_COLLECTION, array('collectionId' => rawurlencode($collectionContent->id))),
                            'roles' => array(
                                'collection'
                            )
                        );
                        $candidates[] = $collectionContent->id;
                        break;
                    }
                }
            }
        }

        // No collection matches the themes => 404
        if (count($candidates) === 0) {
            return RestoLogUtil::httpError(404);
        }

        // Add a STAC search on matching collections
        $this->links[] = array(
            'rel' => 'items',
            'title' => $splitted[1],
            'type' => RestoUtil::$contentTypes['geojson'],
            'href' => $this->context->core['baseUrl'] . '/search?collections=' . join(',', $candidates),
            'method' => 'GET'
        );
    }

    /**
     * Set classifications links
     *
     * @param string $root
     * @return array
     */
    private function setClassificationsLinks($root)
    {
        $facets = $this->stacUtil->getFacetsCount($this->options['minMatch']);
        $target = isset($root) && isset($facets['facets'][$root]) ? $facets['facets'][$root] : $facets['facets'];

        if (isset($target) && is_array($target)) {
            foreach (array_keys($target) as $key) {
                $this->links[] = array(
                    'rel' => 'child',
                    'title' => ucfirst($key),
                    'type' => RestoUtil::$contentTypes['json'],
                    'href' => $this->context->core['baseUrl'] . '/catalogs/' . rawurlencode('facets')  . '/' . (isset($root) ? $root . '/' : '') . rawurlencode($key)
                );
            }
        }
       
        // Set minimalist description
        $this->title = isset($root) ? ucfirst($root) : 'Facets';
        $this->description = isset($root) ? 'Automatic classification of features on facet ' . ucfirst($root) : 'Automatic classification of features';
    }

    /**
     * Initialize child catalogs from facet
     *
     * @param array $params
     * @return array
     */
    private function setCatalogsLinksFromFacet($params)
    {
        $nbOfSegments = count($this->segments);
        $leafValue = $this->segments[$nbOfSegments - 1];
        
        /*
         * Special case for '_' leafValue => compute FeatureCollection of parents
         */
        if ($leafValue === '_') {
            // This is not possible
            if ($nbOfSegments < 2) {
                return RestoLogUtil::httpError(404);
            }

            return $this->setFeatureCollection($this->segments[$nbOfSegments - 2], $params);
        }
        
        // Default segments structure starts with 'resto:classifications/xxxx' - so start at position 2
        $leafPosition = 2;
    
        $where = 'type=$1';
        $whereValues = array(
            $leafValue
        );
        
        // Hashtags special case
        if ($this->segments[0] === 'hashtags' || $this->segments[0] === 'catalogs') {
            
            $leafPosition = 1;

            // In database keyword is 'hashtag' not 'hashtags'
            if ($nbOfSegments === 1) {
                $leafValue = substr($this->segments[0], 0, -1);
                $whereValues = array(
                    $leafValue
                );
            }

            // Hack for catalog - force a hierarchy
            if ($this->segments[0] === 'catalogs') {
                $where = $where . ' AND pid=$2';
                $whereValues[] = 'root';
            }
        }
        
        // Hack for landcover...
        elseif ($this->segments[1] === 'landcover') {
          
            if ($nbOfSegments === 2) {
                $where = 'type LIKE $1';
                $whereValues = array(
                    'landcover:%'
                );
            }
        }

        // Hack for landcover...
        elseif ( in_array($this->segments[1], array_keys($this->stacUtil->classifications))) {
            $leafPosition = 3;
        }

        /*
         * Get description from parent
         */
        $this->setTitleAndDescription($this->segments[$nbOfSegments - 1]);

        try {
            // First get type or pid
            // [TODO]  Return /search items instead of child for high number of results ?
            // $results = $this->context->dbDriver->pQuery('SELECT id, value, isleaf, sum(counter) as matched FROM ' . $this->context->dbDriver->targetSchema . '.facet WHERE ' . ($nbOfSegments === 1 ? 'type LIKE $1' : 'pid=$1' ) . ' GROUP BY id,value,isleaf ORDER BY matched DESC', array(
            $results = $this->context->dbDriver->pQuery('SELECT id, value, pid, isleaf, sum(counter) as matched FROM ' . $this->context->dbDriver->targetSchema . '.facet WHERE ' . ($nbOfSegments === $leafPosition ? $where : 'pid=$1') . ' GROUP BY id, value, pid, isleaf ORDER BY value ASC', $nbOfSegments === $leafPosition ? $whereValues : array($whereValues[0]));

            if (!$results) {
                throw new Exception();
            }

            // No Results - either a wrong path or a leaf facet (except for hashtag)
            if (pg_num_rows($results) === 0) {
                //return $this->setItemsLinks($title, $leafValue);
                if (!in_array($leafValue, array('hashtag')) && $nbOfSegments > 1) {
                    return $this->setFeatureCollection($leafValue, $params);
                }
            }
            
            // Add parent link
            if ($nbOfSegments > 1) {

                // Parent is root in this case
                if ( ! ($this->context->core['mergeRootCatalogLinks'] && $nbOfSegments === 2) ) {
                    $this->links[] = array(
                        'rel' => 'parent',
                        'type' => RestoUtil::$contentTypes['json'],
                        'href' => $this->context->core['baseUrl'] . '/catalogs/' . join('/', array_slice(array_map('rawurlencode', $this->segments), 0, -1))
                    );
                } 
                
            }
            
            $searchIsSet = false;
            while ($result = pg_fetch_assoc($results)) {
                // Add search link for first pid if not root
                if (!$searchIsSet && $result['pid'] !== 'root') {
                    $this->links[] = array(
                        'rel' => 'items',
                        'title' => $this->title,
                        'type' => RestoUtil::$contentTypes['json'],
                        'href' => $this->context->core['baseUrl'] . '/catalogs/' . join('/', array_map('rawurlencode', $this->segments)) . '/_'
                    );
                    $searchIsSet = true;
                }
                
                $matched = (integer) $result['matched'];
                if ($matched >= $this->options['minMatch']) {
                    $link = array(
                        'rel' => ((integer) $result['isleaf']) === 1 ? 'items' : $this->childOrItems($result['id']),
                        'title' => $result['value'],
                        'matched' => $matched,
                        'type' => RestoUtil::$contentTypes['json'],
                        'href' => $this->context->core['baseUrl'] . '/catalogs/' . join('/', array_map('rawurlencode', $this->segments)) . '/' . rawurlencode($result['id'])
                    );

                    // Add a geouid info if present
                    $exploded = explode(':', $result['id']);
                    if (count($exploded) === 3 && ctype_digit($exploded[2])) {
                        $link['geouid'] = (integer) $exploded[2];
                    }
                    
                    $this->links[] = $link;
                }
            }
        } catch (Exception $e) {
            // Keep going
        }
    }

    /**
     * Return 'child' if there is a result, 'items' otherwise
     * 
     * @param string $pid
     */
    private function childOrItems($pid) {
        $results = $this->context->dbDriver->fetch($this->context->dbDriver->pQuery('SELECT id FROM ' . $this->context->dbDriver->targetSchema . '.facet WHERE pid=$1', array($pid)));
        return empty($results) ? 'items' : 'child';
    }

    /**
     * Initialize child items
     *
     * @param string $hashtag
     * @param array $params
     *
     * @return array
     */
    private function setFeatureCollection($hashtag, $params)
    {
        $searchParams = array(
            'q' => '#' . $hashtag
        );

        foreach (array_keys($params) as $key) {
            if ($key !== 'segments') {
                $searchParams[$key] = $params[$key];
            }
        }

        $this->context->query['fields'] = '_all';

        return $this->featureCollection = $this->context->keeper->getRestoCollections($this->user)->load()->search(null, $searchParams);
    }

    /**
     * Get title and description from parentId
     *
     * @param string $facetId
     */
    private function setTitleAndDescription($facetId)
    {
        // Default
        $titleParts = explode(':', $facetId);
        $title = ucfirst(count($titleParts) > 1 ? $titleParts[1] : $titleParts[0]);
        $this->title = $title;
        $this->description = 'Search on ' . $title;

        try {
            $results = $this->context->dbDriver->pQuery('SELECT value, description FROM ' . $this->context->dbDriver->targetSchema . '.facet WHERE public.normalize(id)=public.normalize($1)', array($facetId));
            if (!$results) {
                throw new Exception();
            }
            $result = pg_fetch_assoc($results);
            if (isset($result)) {
                $this->description = $result['description'] ?? $this->description;
                $this->title = $result['value'] ?? $this->title;
            }
        } catch (Exception $e) {
            // Keep going
        }
    }

    /**
     * Convert JSON query to queryParam
     *
     * @param array $jsonQuery
     */
    private function jsonQueryToKVP($jsonQuery)
    {
        return array(
            'query' => 'TODO'
        );
    }

}