app/resto/core/RestoFeatureCollection.php
<?php
/*
* Copyright 2018 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.
*/
/**
* RESTo FeatureCollection
*
* @OA\Schema(
* schema="RestoFeatureCollection",
* description="Feature collection",
* required={"type", "links", "features", "context"},
* @OA\Property(
* property="type",
* type="string",
* enum={"FeatureCollection"},
* description="Always set to *FeatureCollection*"
* ),
* @OA\Property(
* property="features",
* type="array",
* description="Array of features",
* @OA\Items(ref="#/components/schemas/OutputFeature")
* ),
* @OA\Property(
* property="links",
* type="array",
* @OA\Items(ref="#/components/schemas/Link")
* ),
* @OA\Property(
* property="context",
* description="Information on search query",
* required={"returned"},
* @OA\Items(
* @OA\Property(
* property="returned",
* type="integer",
* description="The count of results returned by this response. equal to the cardinality of features array"
* ),
* @OA\Property(
* property="limit",
* type="integer",
* description="The maximum number of results to which the result was limited"
* ),
* @OA\Property(
* property="matched",
* type="integer",
* description="The count of total number of results that match for this query, possibly estimated"
* ),
* @OA\Property(
* property="exactCount",
* type="boolean",
* description="True if *matched* is exact - false means that it is estimated"
* ),
* @OA\Property(
* property="startIndex",
* type="integer",
* description="Start index for the search (cf. pagination)"
* ),
* @OA\Property(
* property="query",
* type="object",
* description="Query details"
* )
* )
* ),
* @OA\Property(
* property="id",
* type="string",
* description="FeatureCollection unique identifier (uuid)"
* ),
* example={
* "type": "FeatureCollection",
* "features":{
* {
* "stac_version": "1.0.0",
* "stac_extensions": {
* "https://stac-extensions.github.io/eo/v1.0.0/schema.json"
* },
* "type": "Feature",
* "id": "8030a391-4002-556f-929b-d7ff9dad6705",
* "bbox": {
* -48.6198530870596,
* 74.6749788966259,
* -44.6464244356188,
* 75.6843970710939
* },
* "geometry": {
* "type": "Polygon",
* "coordinates": {
* {
* {
* -48.619853,
* 75.657209
* },
* {
* -44.646424,
* 75.684397
* },
* {
* -44.660672,
* 75.069386
* },
* {
* -44.698432,
* 75.060518
* },
* {
* -45.489771,
* 74.830977
* },
* {
* -45.857954,
* 74.720238
* },
* {
* -45.921685,
* 74.698702
* },
* {
* -48.392706,
* 74.674979
* },
* {
* -48.619853,
* 75.657209
* }
* }
* }
* },
* "properties": {
* "datetime":"2019-06-11T16:11:41Z",
* "productIdentifier": "S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040",
* "startDate": "2019-06-11T16:11:41.808000Z"
* },
* "collection": "S2",
* "links": {
* {
* "rel": "self",
* "type": "application/json",
* "href": "http://127.0.0.1:5252/collections/S2/items/8030a391-4002-556f-929b-d7ff9dad6705?&lang=en"
* },
* {
* "rel": "collection",
* "type": "application/json",
* "title": "S2",
* "href": "http://127.0.0.1:5252/collections/S2?&lang=en"
* }
* },
* "assets": {
* "thumbnail": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/preview.jpg",
* "type": "image/jpeg"
* },
* "metadata": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/metadata.xml",
* "type": "text/xml"
* },
* "tileInfo": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/tileInfo.json",
* "type": "application/json"
* },
* "productInfo": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/productInfo.json",
* "type": "application/json"
* }
* }
* }
* },
* "links":{
* {
* "rel": "self",
* "type": "application/json",
* "href": "http://127.0.0.1:5252/stac/search.json?"
* },
* {
* "rel": "search",
* "type": "application/opensearchdescription+xml",
* "href": "http://127.0.0.1:5252/services/osdd"
* },
* {
* "rel": "next",
* "type": "application/json",
* "href": "http://127.0.0.1:5252/stac/search.json?next=204449069316703379"
* }
* },
* "context": {
* "returned": 20,
* "limit": 20,
* "matched": 11345,
* "exactCount": false,
* "startIndex": 1,
* "query": {
* "inputFilters": {}
* }
* },
* "id": "20ac2fc6-daee-5621-bca4-d88c0bb19da1"
* }
* )
*
* @OA\Schema(
* schema="InputFeatureCollection",
* description="Feature collection",
* required={"type", "features"},
* @OA\Property(
* property="type",
* type="string",
* enum={"FeatureCollection"},
* description="Always set to *FeatureCollection*"
* ),
* @OA\Property(
* property="features",
* type="array",
* description="Array of features",
* @OA\Items(ref="#/components/schemas/InputFeature")
* ),
* example={
* "type": "FeatureCollection",
* "features":{
* {
* "stac_version": "1.0.0",
* "stac_extensions": {
* "https://stac-extensions.github.io/eo/v1.0.0/schema.json"
* },
* "type": "Feature",
* "id": "8030a391-4002-556f-929b-d7ff9dad6705",
* "bbox": {
* -48.6198530870596,
* 74.6749788966259,
* -44.6464244356188,
* 75.6843970710939
* },
* "geometry": {
* "type": "Polygon",
* "coordinates": {
* {
* {
* -48.619853,
* 75.657209
* },
* {
* -44.646424,
* 75.684397
* },
* {
* -44.660672,
* 75.069386
* },
* {
* -44.698432,
* 75.060518
* },
* {
* -45.489771,
* 74.830977
* },
* {
* -45.857954,
* 74.720238
* },
* {
* -45.921685,
* 74.698702
* },
* {
* -48.392706,
* 74.674979
* },
* {
* -48.619853,
* 75.657209
* }
* }
* }
* },
* "properties": {
* "datetime":"2019-06-11T16:11:41Z",
* "productIdentifier": "S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040",
* "startDate": "2019-06-11T16:11:41.808000Z"
* },
* "collection": "S2",
* "links": {
* {
* "rel": "self",
* "type": "application/json",
* "href": "http://127.0.0.1:5252/collections/S2/items/8030a391-4002-556f-929b-d7ff9dad6705?&lang=en"
* },
* {
* "rel": "collection",
* "type": "application/json",
* "title": "S2",
* "href": "http://127.0.0.1:5252/collections/S2?&lang=en"
* }
* },
* "assets": {
* "thumbnail": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/preview.jpg",
* "type": "image/jpeg"
* },
* "metadata": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/metadata.xml",
* "type": "text/xml"
* },
* "tileInfo": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/tileInfo.json",
* "type": "application/json"
* },
* "productInfo": {
* "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/23/X/MD/2019/6/11/0/productInfo.json",
* "type": "application/json"
* }
* }
* }
* }
* }
* )
*
*
*/
class RestoFeatureCollection
{
/*
* Context
*/
public $context;
/*
* User
*/
public $user;
/*
* Unique identifier
*/
private $id;
/*
* Next iterator
*/
private $next = null;
/*
* Previous iterator
*/
private $prev = null;
/*
* Features
*/
private $restoFeatures = array();
/*
* All collections
*/
private $collections = array();
/*
* Model of the main collection
*/
private $model;
/*
* Query for search
*/
private $query = array();
/*
* Total number of resources relative to the query
*/
private $paging = array();
/**
* Links
*/
private $links = array();
private $requestStartTime;
private $searchContext = array();
/**
* Constructor
*
* @param RestoResto $context : Resto Context
* @param RestoUser $user : Resto user
* @param array $collections
* @param RestoModel $model : Base model
* @param array $query : Search query
*/
public function __construct($context, $user, $collections, $model, $query)
{
$this->context = $context;
$this->user = $user;
$this->collections = $collections;
$this->model = $model ?? new DefaultModel();
$this->query = $query;
}
/**
* Load featureCollection from database
*
* @param RestoCollection $collection
*/
public function load($collection)
{
/*
* Request start time
*/
$this->requestStartTime = microtime(true);
/*
* Convert query to inputFilters
*/
$inputFilters = $this->model->getFiltersFromQuery($this->query);
/*
* result options
*/
$sorting = $this->getSorting($inputFilters);
/*
* Query Analyzer
*/
$analysis = (new RestoQueryAnalyzer($this->context, $this->user))->analyze($inputFilters, $this->model);
/*
* Completely not understood query - return an empty result without
* launching a search on the database
*/
if (isset($analysis['notUnderstood'])) {
$this->restoFeatures = array();
$this->paging = $this->getPaging(array(
'total' => 0,
'isExact' => true
), $sorting['limit'], $sorting['offset']);
}
/*
* Read features from database
*/
else {
/*
* [IMPORTANT] Add explicit 'resto:collection' filter if $collection is set
*/
if (isset($collection)) {
$analysis['details']['appliedFilters'] = array_merge(
$analysis['details']['appliedFilters'],
array('resto:collection' => array(
'value' => $collection->id,
'operation' => '='
)
)
);
}
$this->loadFeatures($analysis['details']['appliedFilters'], $sorting);
}
/*
* Initial values
*/
$this->init($analysis, $sorting, isset($collection) ? $collection->id : null);
/*
* Return object
*/
return $this;
}
/**
* Output product description as a GeoJSON FeatureCollection
*
* @param boolean $pretty : true to return pretty print
*/
public function toJSON($pretty = false)
{
$features = array();
for ($i = 0, $l = count($this->restoFeatures); $i < $l; $i++) {
$features[] = $this->restoFeatures[$i]->toPublicArray();
}
return json_encode(array(
'type' => 'FeatureCollection',
'id' => $this->id,
'context' => $this->searchContext,
'links' => $this->links,
'timeStamp' => date('Y-m-d\TH:i:s\Z', time()),
'numberMatched' => $this->searchContext['matched'],
'numberReturned' => $this->searchContext['returned'],
'features' => $features
), $pretty ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : JSON_UNESCAPED_SLASHES);
}
/**
* Output product description as an ATOM feed
*/
public function toATOM()
{
/*
* Initialize ATOM feed
*/
$atomFeed = new ATOMFeed($this->id, $this->context->core['title'] ?? null, $this->getATOMSubtitle());
/*
* Set collection elements
*/
$atomFeed->setCollectionElements($this->links, $this->searchContext, $this->model);
/*
* Add one entry per product
*/
$atomFeed->addEntries($this->restoFeatures);
/*
* Return ATOM result
*/
return $atomFeed->toString();
}
/**
* Initialize properties based on search query
*
* @param array $analysis
* @param array $sorting
* @param string $collectionId
*/
private function init($analysis, $sorting, $collectionId)
{
/*
* Default name for all collection
*/
$defaultName = $collectionId ?? '*';
/*
* Set id
*/
$this->id = RestoUtil::toUUID($defaultName . ':' . json_encode($this->cleanFilters($analysis['details']['appliedFilters']), JSON_UNESCAPED_SLASHES));
/*
* Set links
*/
$this->links = $this->getLinks($sorting, $collectionId);
/*
* Set context
*/
$this->searchContext = array(
/*'next' => $this->next ?? null,
'prev' => $this->prev ?? null,*/
'returned' => $this->paging['count']['returned'],
'limit' => $sorting['limit'],
'matched' => $this->paging['count']['total'],
'exactCount' => $this->paging['count']['isExact'],
'startIndex' => $sorting['offset'] + 1,
'query' => $this->getSearchQuery($analysis)
);
}
/**
* Return detailed query block from query analysis
*
* @param array $analysis
* @return array
*/
private function getSearchQuery($analysis)
{
/*
* Convert resto model to search service "osKey"
*/
$query = array(
'inputFilters' => $this->model->toOSKeys($analysis['inputFilters']),
'appliedFilters' => $this->model->toOSKeys($analysis['details']['appliedFilters'], true),
'processingTime' => $query['details']['processingTime'] = microtime(true) - $this->requestStartTime
);
/*
* Location found
*/
if (count($analysis['details']['Where']) > 0) {
$query['locationFound'] = $analysis['details']['Where'][0];
}
/*
* Display detailed analysis ?
*/
if (isset($this->query['_analysis']) ? filter_var($this->query['_analysis'], FILTER_VALIDATE_BOOLEAN) : false) {
$query['details'] = $analysis['details'];
}
return $query;
}
/**
* Return an array of request parameters formated for output url
*
* @param {array} $params - input params
*
*/
private function writeRequestParams($params)
{
$arr = array();
foreach ($params as $key => $value) {
/*
* Support key tuples
*/
if (is_array($value)) {
for ($i = 0, $l = count($value); $i < $l; $i++) {
if (isset($this->model->searchFilters[$key]['osKey'])) {
$arr[$this->model->searchFilters[$key]['osKey'] . '[]'] = $value[$i];
} /*else {
$arr[$key . '[]'] = $value;
} */
}
} else {
if (isset($this->model->searchFilters[$key]['osKey'])) {
$arr[$this->model->searchFilters[$key]['osKey']] = $value;
} /* else {
$arr[$key] = $value;
} */
}
}
return $arr;
}
/**
* Set restoFeatures and collections array
*
* @param array $paramsWithOperation
* @param array $sorting
*/
private function loadFeatures($paramsWithOperation, $sorting)
{
/*
* Get features array from database
*/
$featuresArray = (new FeaturesFunctions($this->context->dbDriver))->search(
$this->context,
$this->user,
$this->model,
$this->collections,
$paramsWithOperation,
$sorting
);
/*
* Load collections array
*/
for ($i = 0, $l = count($featuresArray['features']); $i < $l; $i++) {
$feature = new RestoFeature($this->context, $this->user, array(
'featureArray' => $featuresArray['features'][$i],
'collection' => $this->collections[$featuresArray['features'][$i]['collection']] ?? null,
'fields' => $this->query['fields'] ?? null
));
if ($feature->isValid()) {
$this->restoFeatures[] = $feature;
}
}
/*
* Compute paging
*/
$this->paging = $this->getPaging($featuresArray['count'], $sorting['limit'], $sorting['offset']);
/*
* Additional links computed during search (e.g. heatmap - see resto-addon-heatmap)
*/
if (!empty($featuresArray['links'])) {
$this->links = array_merge($this->links, $featuresArray['links']);
}
}
/**
* Search offset - first element starts at offset 0
* Note: startPage has preseance over startIndex if both are specified in request
* (see CEOS-BP-006 requirement of CEOS OpenSearch Best Practice document)
*
* @param array $params
* @param integer $limit
*/
public function getOffset($params, $limit)
{
$offset = 0;
if (isset($params['startPage']) && is_numeric($params['startPage']) && $params['startPage'] > 0) {
$offset = (($params['startPage'] - 1) * $limit);
} elseif (isset($params['startIndex']) && is_numeric($params['startIndex']) && $params['startIndex'] > 0) {
$offset = ($params['startIndex']) - 1;
}
/*
* Limit offset to avoid very bad performance - Even Google does the same !
*/
if ($offset > 999) {
RestoLogUtil::httpError(400, 'Offset pagination is limited to 1000 elements');
}
return $offset;
}
/**
* Get navigation links (i.e. next, previous, first, last)
*
* @param array $sorting
* @param string $collectionId
*
* @return array
*/
private function getLinks($sorting, $collectionId)
{
/*
* Base links are always returned
*/
$this->getBaseLinks($collectionId);
/*
* resto:lt has preseance over startPage
*/
if ($sorting['resto:lt'] || $sorting['resto:gt']) {
if (isset($this->restoFeatures[0])) {
$featureArray = $this->restoFeatures[0]->toArray();
// No sort_idx (e.g. timeless collection)
if (isset($featureArray['properties']['sort_idx'])) {
$this->prev = $featureArray['properties']['sort_idx'];
/*
* Previous
*/
$this->links[] = $this->getLink(
'previous',
array(
'resto:lt' => null,
'resto:gt' => $this->prev,
'count' => $sorting['limit'])
);
}
}
/*
* First URL is the first search URL i.e. without any lt/gt
*/
$this->links[] = $this->getLink(
'first',
array(
'resto:lt' => null,
'resto:gt' => null,
'count' => $sorting['limit'])
);
}
/*
* Start page cannot be lower than 1
*/
elseif ($this->paging['startPage'] > 1) {
/*
* Previous URL is the previous URL from the self URL
*
*/
$this->links[] = $this->getLink('previous', array(
'startPage' => max($this->paging['startPage'] - 1, 1),
'count' => $sorting['limit']));
/*
* First URL is the first search URL i.e. with startPage = 1
*/
$this->links[] = $this->getLink(
'first',
array(
'startPage' => 1,
'count' => $sorting['limit'])
);
}
/*
* Theorically, startPage cannot be greater than the one from lastURL
* ...but since we use a count estimate it is not possible to know the
* real last page. So always set a nextPage !
*/
$count = count($this->restoFeatures);
if ($count >= $sorting['limit']) {
$featureArray = $this->restoFeatures[$count - 1]->toArray();
// No sort_idx (e.g. timeless collection)
if (isset($featureArray['properties']['sort_idx'])) {
$this->next = $featureArray['properties']['sort_idx'];
/*
* Next URL is the next search URL from the self URL
*/
$this->links[] = $this->getLink(
'next',
array(
'resto:gt' => null,
'resto:lt' => $this->next,
'count' => $sorting['limit'])
);
}
}
return $this->links;
}
/**
* Return base links (i.e. links always present in response)
*
* @param string $collectionId
*/
private function getBaseLinks($collectionId)
{
$this->links = array_merge($this->links, array(
array(
'rel' => 'self',
'type' => RestoUtil::$contentTypes['geojson'],
'href' => RestoUtil::updateUrl($this->context->getUrl(false), $this->writeRequestParams($this->query))
),
array(
'rel' => 'search',
'type' => 'application/opensearchdescription+xml',
'href' => $this->context->core['baseUrl'] . '/services/osdd' . (isset($collectionId) ? '/' . $collectionId : '')
),
array(
'rel' => 'root',
'type' => RestoUtil::$contentTypes['json'],
'href' => $this->context->core['baseUrl']
)
));
}
/**
* Return Link
*
* @param string $rel
* @param array $params
* @return array
*/
private function getLink($rel, $params)
{
/*
* Do not set count if equal to default limit
*/
if (isset($params['count']) && $params['count'] === $this->context->dbDriver->resultsPerPage) {
unset($params['count']);
}
return array(
'rel' => $rel,
'type' => RestoUtil::$contentTypes['geojson'],
'href' => RestoUtil::updateUrl($this->context->getUrl(false), $this->writeRequestParams(array_merge($this->query, $params)))
);
}
/**
* Get start, next and last page from limit and offset
*
* @param array $count
* @param integer $limit
* @param integer $offset
*/
private function getPaging($count, $limit, $offset)
{
$count['returned'] = count($this->restoFeatures);
/*
* If first page contains no features count must be 0 not estimated value
*/
if ($offset == 0 && $count['returned'] == 0) {
$count = array(
'returned' => 0,
'total' => $count['total'] ?? 0,
'isExact' => true
);
}
// Avoid Math problem
if ($limit < 0) {
$limit = 1;
}
/*
* Default paging
*/
$paging = array(
'count' => $count,
'startPage' => 1,
'nextPage' => 1,
'totalPage' => 0,
'itemsPerPage' => $limit
);
if ($count['returned'] > 0) {
$startPage = ceil(($offset + 1) / $limit);
/*
* Tricky part if count is estimate, then
* the total count is the maximum between the database estimate
* and the pseudo real count based on the retrieved features count
*/
if (!$count['isExact']) {
$count['total'] = max($count['returned'] + (($startPage - 1) * $limit), $count['total']);
}
$totalPage = ceil($count['total'] / $limit);
$paging = array(
'count' => $count,
'startPage' => $startPage,
'nextPage' => $startPage + 1,
'totalPage' => $totalPage,
'itemsPerPage' => $limit
);
}
return $paging;
}
/**
* Return query array from search filters
*
* @param array $searchFilters
* @return array
*/
private function cleanFilters($searchFilters)
{
$query = array();
$exclude = array(
'count',
'startIndex',
'startPage'
);
foreach ($searchFilters as $key => $obj) {
if (in_array($key, $exclude)) {
continue;
}
$query[$key] = $obj;
if ($key === 'searchTerms') {
$query[$key]['value'] = stripslashes($obj['value']);
}
}
ksort($query);
return $query;
}
/**
* Get ATOM subtitle
*
* @return string
*/
private function getATOMSubtitle()
{
$subtitle = '';
if (isset($this->searchContext['totalResults']) && $this->searchContext['totalResults'] !== -1) {
$subtitle = $this->searchContext['totalResults'] . ($this->searchContext['totalResults'] > 1 ? 'results' : 'result');
}
if (isset($this->searchContext['startIndex'])) {
$previous = isset($this->links['previous']) ? '<a href="' . RestoUtil::updateUrlFormat($this->links['previous'], 'atom') . '">Previous</a> ' : '';
$next = isset($this->links['next']) ? ' <a href="' . RestoUtil::updateUrlFormat($this->links['next'], 'atom') . '">Next</a>' : '';
return $subtitle . ' | ' . $previous . $this->searchContext['startIndex'] . ' - ' . ($this->searchContext['startIndex'] + 1) . $next;
}
return $subtitle;
}
/**
* Returns sorting paramers (offset,limit & sorting)
*
* @param array $filters
*/
private function getSorting($filters)
{
/*
* Number of returned results is never greater than maxInclusive
*/
$limit = isset($filters['count']) && is_numeric($filters['count']) ? min($filters['count'], $this->model->searchFilters['count']['maxInclusive']) : $this->context->dbDriver->resultsPerPage;
/*
* Compute offset based on startPage or startIndex
*/
$offset = $this->getOffset($filters, $limit);
/*
* Default order is DESCENDING
*/
$sortOrder = 'DESC';
/*
* Default sort key is the first element in "sortKeys" array (cf. config.php)
*/
$sortKey = $filters['resto:sort'] ?? $this->context->dbDriver->sortKeys[0];
/*
* Check sortKey order (i.e. minus sign prefix)
*/
if (substr($sortKey, 0, 1) === '-') {
$sortOrder = 'ASC';
$sortKey = substr($sortKey, 1);
}
/*
* Finally check validity
*/
if (! in_array($sortKey, $this->context->dbDriver->sortKeys)) {
return RestoLogUtil::httpError(400, "Invalid sorting key");
}
/*
* Result options
*/
return array(
'offset' => $offset,
'limit' => $limit,
'sortKey' => $sortKey === 'likes' ? $sortKey : $sortKey . '_idx',
'order' => $sortOrder,
// [IMPORTANT] We need to force ASC order to make "previous" link working
'realOrder' => isset($filters['resto:gt']) ? 'ASC' : $sortOrder,
'resto:lt' => isset($filters['resto:lt']) ? $filters['resto:lt'] : null,
'resto:gt' => isset($filters['resto:gt']) ? $filters['resto:gt'] : null
);
}
}