From 7a3d394ca93d8db14785ca846dc63db998bc4448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Dvo=C5=99=C3=A1k?= Date: Fri, 11 Mar 2022 09:24:12 +0100 Subject: [PATCH 01/27] Do not force SMTP authentization --- app/resto/core/RestoNotifier.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/resto/core/RestoNotifier.php b/app/resto/core/RestoNotifier.php index 7e8adb20..58037b01 100755 --- a/app/resto/core/RestoNotifier.php +++ b/app/resto/core/RestoNotifier.php @@ -129,14 +129,16 @@ public function sendMail($params, $smtp = array()) } $mail->isSMTP(); // Set mailer to use SMTP $mail->Host = $smtp['host']; // Specify main and backup SMTP servers - if (isset($smtp['secure'])) { + if (isset($smtp['secure']) && $smtp['secure'] != "" && $smtp['secure'] != 'none') { $mail->SMTPSecure = $smtp['secure']; // Enable TLS encryption, `ssl` also accepted } $mail->Port = $smtp['port']; // TCP port to connect to - if (isset($smtp['auth'])) { + if (isset($smtp['auth']) && isset($smtp['auth']['user']) && $smtp['auth']['user'] != "" && $smtp['auth']['user'] != "xxx") { $mail->SMTPAuth = true; // Enable SMTP authentication $mail->Username = $smtp['auth']['user']; // SMTP username $mail->Password = $smtp['auth']['password']; // SMTP password + } else { + $mail->SMTPAuth = false; } } From 31d2f18a3c6d086b296fb5b1c1348d1930cd8eca Mon Sep 17 00:00:00 2001 From: Jerome Gasperi Date: Wed, 29 Jun 2022 07:16:00 +0200 Subject: [PATCH 02/27] Add LICENSE file --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. From e51176ca588c147f1bdd148fec83e4694b01dc94 Mon Sep 17 00:00:00 2001 From: Jerome Gasperi Date: Wed, 29 Jun 2022 07:20:03 +0200 Subject: [PATCH 03/27] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++ .github/ISSUE_TEMPLATE/issue-report.md | 17 ++++++++++ 3 files changed, 75 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/issue-report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/issue-report.md b/.github/ISSUE_TEMPLATE/issue-report.md new file mode 100644 index 00000000..6c741b14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-report.md @@ -0,0 +1,17 @@ +--- +name: Issue report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the issue** +A clear and concise description of what the issue is. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. From cddb34fb9af066cda2a0288759c32ae160c62d84 Mon Sep 17 00:00:00 2001 From: Cesare Rossi Date: Tue, 18 Jul 2023 22:56:11 +0200 Subject: [PATCH 04/27] Update README.md fixing the example on datetime --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 75406aef..42143d12 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Or test the API : * Get STAC root endpoint - https://tamn.snapplanet.io/?_pretty=1 * Get all collections - https://tamn.snapplanet.io/collections?_pretty=1 -* Search Sentinel-2 products acquired on June 1st, 2021 - https://tamn.snapplanet.io/collections/S2/items?datetime=2021-05-06T00:00:00/2021-06-01T23:59:59&_pretty=1 +* Search Sentinel-2 products acquired on June 1st, 2021 - https://tamn.snapplanet.io/collections/S2/items?datetime=2021-05-06T00:00:00Z/2021-06-01T23:59:59Z&_pretty=1 * Get catalogs of products classified by continents - https://tamn.snapplanet.io/catalogs/classifications/geographical/continent?_pretty=1 * Get catalogs of products classified by european countries - https://tamn.snapplanet.io/catalogs/classifications/geographical/continent/continent:Europe:6255148?_pretty=1 From ec61e5f86da774b498dc982329ec09910b9230fb Mon Sep 17 00:00:00 2001 From: Mathis <51701372+nitreb@users.noreply.github.com> Date: Fri, 24 May 2024 14:23:52 +0000 Subject: [PATCH 05/27] Fix typo in keysValues variable --- app/resto/core/dbfunctions/FeaturesFunctions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/resto/core/dbfunctions/FeaturesFunctions.php b/app/resto/core/dbfunctions/FeaturesFunctions.php index 642fd95a..2f2f2cdc 100755 --- a/app/resto/core/dbfunctions/FeaturesFunctions.php +++ b/app/resto/core/dbfunctions/FeaturesFunctions.php @@ -316,7 +316,7 @@ public function storeFeature($id, $collection, $featureArray) */ $facetsStored = $collection->context->core['storeFacets'] && $collection->model->dbParams['storeFacets']; if ($facetsStored) { - foreach (array_values($keysAndValues['facets']) as $facetElement) { + foreach (array_values($keysValues['facets']) as $facetElement) { if (isset($facetElement['type']) && $facetElement['type'] === 'catalog') { if ( !$collection->user->hasRightsTo(RestoUser::CREATE_CATALOG) ) { return RestoLogUtil::httpError(403, 'Feature ingestion leads to creation of catalog ' . $facetElement['id'] . ' but you don\'t have right to create catalogs'); From 4b3a59d50faf82d82096da58cd45022958d68f47 Mon Sep 17 00:00:00 2001 From: jjrom Date: Thu, 6 Jun 2024 09:49:32 +0200 Subject: [PATCH 06/27] storeFeature works --- app/resto/core/RestoCollection.php | 2 +- app/resto/core/RestoCollections.php | 2 +- app/resto/core/RestoContext.php | 3 - app/resto/core/RestoModel.php | 31 +- app/resto/core/addons/Cataloger.php | 583 ++++++++++++++++++ app/resto/core/addons/Tag.php | 462 -------------- .../core/dbfunctions/CatalogsFunctions.php | 407 ++++++++++++ .../core/dbfunctions/FeaturesFunctions.php | 171 ++--- app/resto/core/models/LandCoverModel.php | 2 +- build/resto/config.php.template | 2 +- ...901_N0207_R140_T23XMD_20190611T193040.json | 10 +- resto-database-model/01_resto_functions.sql | 90 +++ .../03_resto_target_model.sql | 65 ++ 13 files changed, 1243 insertions(+), 587 deletions(-) create mode 100644 app/resto/core/addons/Cataloger.php delete mode 100644 app/resto/core/addons/Tag.php create mode 100755 app/resto/core/dbfunctions/CatalogsFunctions.php diff --git a/app/resto/core/RestoCollection.php b/app/resto/core/RestoCollection.php index c93061b4..d14165d1 100755 --- a/app/resto/core/RestoCollection.php +++ b/app/resto/core/RestoCollection.php @@ -944,7 +944,7 @@ public function addFeatures($body, $params) public function getSummaries() { if ( !isset($this->summaries) ) { - $summaries = (new FacetsFunctions($this->context->dbDriver))->getSummaries(null, $this->id); + $summaries = (new CatalogsFunctions($this->context->dbDriver))->getSummaries(null, $this->id); if ( isset($summaries[$this->id]) ) { $this->setSummaries($summaries[$this->id]); } diff --git a/app/resto/core/RestoCollections.php b/app/resto/core/RestoCollections.php index 8cbe8fcf..00d96ed4 100755 --- a/app/resto/core/RestoCollections.php +++ b/app/resto/core/RestoCollections.php @@ -327,7 +327,7 @@ public function getOSDD($model) public function getSummaries() { if ( !isset($this->summaries) ) { - $this->summaries = (new FacetsFunctions($this->context->dbDriver))->getSummaries(null, null); + $this->summaries = (new CatalogsFunctions($this->context->dbDriver))->getSummaries(null, null); } return $this->summaries; } diff --git a/app/resto/core/RestoContext.php b/app/resto/core/RestoContext.php index 38f8b6a5..27c0a739 100755 --- a/app/resto/core/RestoContext.php +++ b/app/resto/core/RestoContext.php @@ -47,9 +47,6 @@ class RestoContext // Shared links validity duration (in seconds) 'sharedLinkDuration' => 86400, - // True to store count statistics for each POST,PUT,DELETE on features - 'storeFacets' => true, - // True to store all user queries to database 'storeQuery' => false, diff --git a/app/resto/core/RestoModel.php b/app/resto/core/RestoModel.php index f07a5450..f48dcb0a 100755 --- a/app/resto/core/RestoModel.php +++ b/app/resto/core/RestoModel.php @@ -361,11 +361,9 @@ abstract class RestoModel * Parameters to apply to database storage for products related to this model * * - tablePrefix : all features belonging to a collection referencing this model will be stored in a dedicated table [tablePrefix]__feature instead of feature" - * - storeFacets = if true, facets are stored for model related products */ public $dbParams = array( - 'tablePrefix' => '', - 'storeFacets' => true + 'tablePrefix' => '' ); /* @@ -443,12 +441,11 @@ public function storeFeatures($collection, $body, $params) // Feature case if ($data['type'] === 'Feature') { $insert = $this->storeFeature($collection, $data, $params); - - if ($insert['result'] !== false) { + if ( isset($insert['result']) ) { $featuresInserted[] = array( 'featureId' => $insert['result']['id'], 'productIdentifier' => $insert['result']['productIdentifier'], - 'facetsStored' => $insert['result']['facetsStored'] + 'catalogsStored' => $insert['result']['catalogsStored'] ); $dates[] = isset($insert['featureArray']['properties']) && isset($insert['featureArray']['properties']['startDate']) ? $insert['featureArray']['properties']['startDate'] : null; @@ -465,7 +462,7 @@ public function storeFeatures($collection, $body, $params) $featuresInserted[] = array( 'featureId' => $insert['result']['id'], 'productIdentifier' => $insert['result']['productIdentifier'], - 'facetsStored' => $insert['result']['facetsStored'] + 'catalogsStored' => $insert['result']['catalogsStored'] ); $dates[] = isset($insert['featureArray']['properties']) && isset($insert['featureArray']['properties']['startDate']) ? $insert['featureArray']['properties']['startDate'] : null; @@ -917,16 +914,22 @@ private function prepareFeatureArray($collection, $data, $params = array()) RestoLogUtil::httpError(400, $topologyAnalysis['error']); } + // Compute catalogs associated with this feature + // Eventually remove input _catalogs from properties + $catalogs = (new Cataloger($collection->context, $collection->user))->getCatalogs($properties, $data['geometry'] ?? null, $collection, $this->getITagParams($collection)); + if (isset($properties['resto:catalogs'])) { + unset($properties['resto:catalogs']); + } + /* * Return prepared data */ return array( 'topologyAnalysis' => $topologyAnalysis, - 'properties' => array_merge($properties, array( - 'keywords' => (new Tag($collection->context, $collection->user))->getKeywords($properties, $data['geometry'] ?? null, $collection->model, $this->getITagParams($collection)) - )), + 'properties' => $properties, 'assets' => $data['assets'] ?? null, - 'links' => $data['links'] ?? null + 'links' => $data['links'] ?? null, + 'catalogs' => $catalogs ); } @@ -948,9 +951,9 @@ private function getITagParams($collection) /* * Default is to convert array of string to associative array */ - if ($collection->context->addons['Tag']['options']['iTag']['taggers']) { - for ($i = 0, $ii = count($collection->context->addons['Tag']['options']['iTag']['taggers']); $i < $ii; $i++) { - $taggers[$collection->context->addons['Tag']['options']['iTag']['taggers'][$i]] = array(); + if ($collection->context->addons['Cataloger']['options']['iTag']['taggers']) { + for ($i = 0, $ii = count($collection->context->addons['Cataloger']['options']['iTag']['taggers']); $i < $ii; $i++) { + $taggers[$collection->context->addons['Cataloger']['options']['iTag']['taggers'][$i]] = array(); } } diff --git a/app/resto/core/addons/Cataloger.php b/app/resto/core/addons/Cataloger.php new file mode 100644 index 00000000..0a48f851 --- /dev/null +++ b/app/resto/core/addons/Cataloger.php @@ -0,0 +1,583 @@ +catalogsFromITag($properties, $geometry, $iTagParams), $this->catalogsFromProperties($properties, $collection)) : $this->catalogsFromProperties($properties, $collection); + } + + /** + * Extract hashtags from a text and return catalogs - invalid characters are discarded + * + * [WARNING] The leading '#' is not returned + * + * Example: + * + * $text = "This is a #test #withA!%.badhashtag" + * + * returns: + * + * array( + * array( + * 'id' => 'hashtags/test', + * 'title' => 'test', + * 'description' => 'Catalog of features containing hashtag #test' + * ), + * array( + * 'id' => 'hashtags/withabadhashtag', + * 'title' => 'test', + * 'description' => 'Catalog of features containing hashtag #withabadhashtag' + * ) + * ) + * + * @param string $text + * + * @return array + */ + public function catalogsFromText($text) + { + $matches = null; + if (isset($text)) { + preg_match_all("/#([^ ]+)/u", RestoUtil::cleanHashtag($text), $matches); + if ($matches) { + $hashtagsArray = array_count_values($matches[1]); + $catalogs = array( + array( + 'id' => 'hashtags', + 'title' => 'hashtags', + 'description' => 'Catalog of features per hashtags' + ) + ); + foreach (array_keys($hashtagsArray) as $key) { + $catalogs[] = array( + 'id' => 'hashtags/' . $key, + 'title' => $key, + 'description' => 'Catalog of features containing hashtag #' . $key, + 'rtype' => 'hashtag', + 'hashtag' => $key + ); + } + return $catalogs; + } + } + return array(); + } + + /** + * Return a RESTo catalogs array from an iTag Hierarchical feature + * + * @param array $properties + * @param array $geometry (GeoJSON) + * @param array $iTagParams + */ + private function catalogsFromITag($properties, $geometry, $iTagParams) + { + + /* + * No geometry = no iTag + * + * [TODO] Add support to null geometry in iTag instead + */ + if (! isset($geometry)) { + return array(); + } + + $taggerKeys = array_keys($iTagParams['taggers']); + $queryParams = array( + 'geometry' => RestoGeometryUtil::geoJSONGeometryToWKT($geometry), + 'taggers' => join(',', $taggerKeys), + 'planet' => $iTagParams['planet'] ?? $this->context->core['planet'] + ); + + if (isset($properties['startDate'])) { + $queryParams['timestamp'] = $properties['startDate']; + } + + /* + * Convert taggers options to query params + */ + for ($i = count($taggerKeys); $i--;) { + foreach ($iTagParams['taggers'][$taggerKeys[$i]] as $optionName => $optionValue) { + $queryParams[strtolower($taggerKeys[$i]) . '_' . $optionName] = $optionValue; + } + } + + try { + $curl = new Curly(); + $iTagFeature = json_decode($curl->get($this->options['iTag']['endpoint'] . '?' . http_build_query($queryParams)), true); + $curl->close(); + } catch (Exception $e) { + $curl->close(); + RestoLogUtil::httpError($e->getCode(), $e->getMessage()); + } + + /* + * Return empty result + */ + if (!isset($iTagFeature) || !isset($iTagFeature['content'])) { + return array(); + } + + return $this->processITagKeywords($iTagFeature); + } + + /** + * Convert iTag keywords to catalogs + * + * @param array $iTagFeature + */ + private function processITagKeywords($iTagFeature) + { + + $catalogs = array(); + + /* + * Process keywords + */ + foreach ($iTagFeature['content'] as $key => $value) { + switch ($key) { + + case 'political': + if (isset($value['continents'])) { + $catalogs[] = array( + 'id' => 'continents', + 'title' => 'Continents', + 'description' => 'Automatic geographical classification processed by [iTag](https://github.com/jjrom/itag)' + ); + $catalogs = array_merge($catalogs, $this->getCatalogsFromGeneric($value['continents'], 'continents')); + } + break; + + case 'landcover': + $catalogs[] = array( + 'id' => 'landcover', + 'title' => 'Landcover classification', + 'description' => 'Automatic landcover classification processed by [iTag](https://github.com/jjrom/itag)' + ); + $catalogs = array_merge($catalogs, $this->getCatalogsFromLandCover($value, 'landcover')); + break; + + case 'population': + $catalogs[] = array( + 'id' => 'population', + 'title' => 'Population', + 'isNotACatalog' => true, + 'properties' => array( + 'itag:population' => array( + 'count' => $value['count'], + 'densityPerSquareKm' => $value['densityPerSquareKm'] + ) + ) + ); + break; + + case 'keywords': + $catalogs = array_merge($catalogs, $this->getCatalogsFromAlways($value)); + break; + + default: + if (is_array($value)) { + $catalogs = array_merge($catalogs, $this->getCatalogsFromGeneric($value)); + } + } + } + + return $catalogs; + } + + /** + * Get keywords from iTag 'keywords' property + * + * @param array $properties + */ + private function getCatalogsFromAlways($properties) + { + + $catalogs = array(); + + foreach (array_values($properties) as $typeAndId) { + $exploded = explode(RestoConstants::TAG_SEPARATOR, $typeAndId); + $parentId = substr($exploded[0], -1) === 's' ? $exploded[0] : $exploded[0] . 's'; + if (!$this->alreadyExists($catalogs, $parentId)) { + $catalogs[] = array( + 'id' => $parentId, + 'title' => ucfirst($parentId), + 'description' => 'Catalogs of features per ' . $exploded[0] + ); + } + $id = $parentId . '/' . $exploded[1]; + if (!$this->alreadyExists($catalogs, $id)) { + $catalogs[] = array( + 'id' => $id, + 'title' => ucfirst($exploded[1]), + 'description' => 'Catalogs of features per ' . $exploded[0] . ' ' . $exploded[1], + 'rtype' => $exploded[0], + 'hashtag' => $exploded[0] . RestoConstants::TAG_SEPARATOR . $exploded[1] + ); + } + } + return $catalogs; + } + + /** + * Get Landcover catalogs + * + * @param array $properties + * @param string $parentId + */ + private function getCatalogsFromLandCover($properties, $parentId) + { + $catalogs = array(); + + /* + * Main landcover + */ + if (isset($properties['main'])) { + foreach (array_values($properties['main']) as $landcover) { + $exploded = explode(RestoConstants::TAG_SEPARATOR, $landcover['id']); + $id = $parentId . '/' . $exploded[1]; + if ( !$this->alreadyExists($catalogs, $id) ) { + $catalogs[] = array( + 'id' => $id, + 'title' => $landcover['name'], + 'rtype' => $exploded[0], + 'hashtag' => $landcover['id'], + 'properties' => array( + 'itag' . RestoConstants::TAG_SEPARATOR . $landcover['id'] => array( + 'area' => $landcover['area'], + 'pcover' => $landcover['pcover'] + ) + ) + ); + } + } + } + + return $catalogs; + } + + /** + * Get generic keywords + * + * @param array $properties + * @param array $options + * @return array + */ + private function getCatalogsFromGeneric($properties, $parentId = null) + { + $catalogs = array(); + for ($i = 0, $ii = count($properties); $i < $ii; $i++) { + $catalog = $this->getCatalogFromGeneric($properties[$i], $parentId); + if (! $this->alreadyExists($catalogs, $catalog['id'])) { + $catalogs[] = $catalog; + + switch ($catalog['rtype']) { + + case 'continent': + $catalogs = array_merge($catalogs, $this->getCatalogsFromGeneric($properties[$i]['countries'], $catalog['id'])); + break; + case 'country': + if (isset($properties[$i]['regions'])) { + $catalogs = array_merge($catalogs, $this->getCatalogsFromGeneric($properties[$i]['regions'], $catalog['id'])); + } + break; + case 'region': + $catalogs = array_merge($catalogs, $this->getCatalogsFromGeneric($properties[$i]['states'], $catalog['id'])); + break; + default: + break; + } + } + } + + return $catalogs; + } + + /** + * Get generic keyword + * + * @param array $property + * @param string $parentId + * + */ + private function getCatalogFromGeneric($property, $parentId) + { + $exploded = explode(RestoConstants::TAG_SEPARATOR, $property['id']); + + $catalog = array( + 'id' => (isset($parentId) ? $parentId : '') . '/' . $exploded[1], + 'title' => $property['name'] ?? $exploded[1], + 'rtype' => $exploded[0], + 'hashtag' => $property['id'] + ); + + $properties = array(); + + if (isset($property['area'])) { + $properties['area'] = $property['area']; + } + if (isset($property['pcover'])) { + $properties['pcover'] = $property['pcover']; + } + + /* + * Absolute coverage of geographical entity + */ + if (isset($property['gcover'])) { + $properties['gcover'] = $property['gcover']; + } + + if ( !empty($properties) ) { + $catalog['properties'] = array( + 'itag' . RestoConstants::TAG_SEPARATOR . $property['id'] => $properties + ); + } + + return $catalog; + } + + /** + * Return a catalogs array from feature properties + * + * @param array $properties + * @param RestoCollection $collection + */ + private function catalogsFromProperties($properties, $collection) + { + /* + * [IMPORTANT] If input properties contains a resto:catalogs property then use it + * [SECURITY] The isExternal is automatically set to true for these catalogs to + * avoid non authorized user to create a catalog if he doesn't have rights to do so + */ + $catalogs = $properties['resto:catalogs'] ?? array(); + for ($i = count($catalogs); $i--;) { + $exploded = explode('/', $catalogs[$i]['id']); + $catalogs[$i]['isExternal'] = true; + $catalogs[$i]['rtype'] = 'catalog'; + $catalogs[$i]['hashtag'] = 'catalog' . RestoConstants::TAG_SEPARATOR . array_pop($exploded); + } + + /* + * Roll over facet categories + */ + $model = isset($collection) ? $collection->model : new DefaultModel(); + foreach (array_values($model->facetCategories) as $facetCategory) { + $catalogs = array_merge($catalogs, $this->catalogsFromFacets($properties, $facetCategory, $model)); + } + + /* + * Compute hashtags catalogs from description + */ + $hashtags = $this->catalogsFromText($properties['description'] ?? ''); + if ( !empty($hashtags) ) { + $catalogs = array_merge($catalogs, $hashtags); + } + + /* + * Finnaly date catalogs + */ + return array_merge($catalogs, $this->getDateCatalogs($properties, $model)); + } + + /** + * Process catalogs for facets + * + * @param array $properties + * @param array $facetCategory + * @param RestoModel $model + * @return array + */ + private function catalogsFromFacets($properties, $facetCategory, $model) + { + $parentId = null; + $catalogs = array(); + + for ($i = 0, $ii = count($facetCategory); $i < $ii; $i++) { + // Get value in properties for input facetCategory + $value = $properties[$facetCategory[$i]] ?? null; + + // If the facetCategory is not found, try the STAC alias + if (!isset($value) && isset($model->stacMapping[$facetCategory[$i]])) { + $value = $properties[$model->stacMapping[$facetCategory[$i]]['key']] ?? null; + } + + if (isset($value)) { + // If input property is an array then split into individuals values + // This is to support "instruments" for instance + // [IMPORTANT] Only a leaf (i.e. the last child) can be an array with more than 1 element + // otherwise parentId computation can be erroneous + if (! is_array($value)) { + $value = array($value); + } + + $newParentId = null; + for ($j = 0, $jj = count($value); $j < $jj; $j++) { + // If parentId is null, create a root catalog using plural of facetCategory + if ( !isset($parentId) ) { + $parentId = $facetCategory[$i] . (substr($facetCategory[$i], -1) === 's' ? '' : 's'); + if (! $this->alreadyExists($catalogs, $parentId)) { + $catalogs[] = array( + 'id' => $parentId, + 'title' => $parentId, + 'description' => 'Catalog of features per ' . $facetCategory[$i] + ); + } + } + // [IMPORTANT] Remove spaces from id + $id = $parentId . '/' . str_replace(' ', '', $value[$j]); + if (! $this->alreadyExists($catalogs, $id)) { + $catalogs[] = array( + 'id' => $id, + 'title' => $value[$j], + 'description' => 'Catalog of features for ' . $facetCategory[$i] . '' . $value[$j], + 'rtype' => $facetCategory[$i], + 'hashtag' => $facetCategory[$i] . RestoConstants::TAG_SEPARATOR . $value[$j] + ); + $newParentId = $id; + } + } + if ($newParentId) { + $parentId = $id; + } + } else { + $parentId = null; + } + } + return $catalogs; + } + + /** + * Convert date keywords to catalogs + * + * @param array $properties + * @param RestoModel $model + * @return array + */ + private function getDateCatalogs($properties, $model) + { + + $startDate = $properties[$model->searchFilters['time:start']['key']]; + + /* + * No startDate property or both startDate and completionDate are presents => range is not supported + */ + if (! isset($startDate) || (isset($startDate) && isset($properties['completionDate']))) { + return array(); + } + + /* + * Year + */ + $year = substr($startDate, 0, 4); + + /* + * Month + */ + $month = substr($startDate, 5, 2); + + /* + * Day + */ + $day = substr($startDate, 8, 2); + + return array( + array( + 'id' => 'years', + 'title' => 'years', + 'description' => 'Catalog of features per year' + ), + array( + 'id' => 'years/' . $year, + 'title' => $year, + 'rtype' => 'year', + 'hashtag' => 'year' . RestoConstants::TAG_SEPARATOR . $year + ), + array( + 'id' => 'years/' . $year . '/' . $month, + 'title' => $month, + 'rtype' => 'month', + 'hashtag' => 'month' . RestoConstants::TAG_SEPARATOR . $month + ), + array( + 'id' => 'years/' . $year . '/' . $month . '/' . $day, + 'title' => $day, + 'rtype' => 'day', + 'hashtag' => 'day' . RestoConstants::TAG_SEPARATOR . $day + ) + ); + } + + /** + * Return true if id exists in keywords array + * + * @param array $catalogs + * @param string $identifier + */ + private function alreadyExists($catalogs, $identifier) + { + for ($i = count($catalogs); $i--;) { + if ($identifier === $catalogs[$i]['id']) { + return true; + } + } + return false; + } +} diff --git a/app/resto/core/addons/Tag.php b/app/resto/core/addons/Tag.php deleted file mode 100644 index 9eb0cf3c..00000000 --- a/app/resto/core/addons/Tag.php +++ /dev/null @@ -1,462 +0,0 @@ -keywordsFromITag($properties, $geometry, $iTagParams), $this->keywordsFromProperties($properties, $model)) : $this->keywordsFromProperties($properties, $model); - } - - /** - * Return a RESTo keywords array from an iTag Hierarchical feature - * - * @param array $properties - * @param array $geometry (GeoJSON) - * @param array $iTagParams - */ - private function keywordsFromITag($properties, $geometry, $iTagParams) - { - /* - * No geometry = no iTag - * - * [TODO] Add support to null geometry in iTag instead - */ - if (! isset($geometry)) { - return array(); - } - - $taggerKeys = array_keys($iTagParams['taggers']); - $queryParams = array( - 'geometry' => RestoGeometryUtil::geoJSONGeometryToWKT($geometry), - 'taggers' => join(',', $taggerKeys), - 'planet' => $iTagParams['planet'] ?? $this->context->core['planet'] - ); - - if (isset($properties['startDate'])) { - $queryParams['timestamp'] = $properties['startDate']; - } - - /* - * Convert taggers options to query params - */ - for ($i = count($taggerKeys); $i--;) { - foreach ($iTagParams['taggers'][$taggerKeys[$i]] as $optionName => $optionValue) { - $queryParams[strtolower($taggerKeys[$i]) . '_' . $optionName] = $optionValue; - } - } - - try { - $curl = new Curly(); - $iTagFeature = json_decode($curl->get($this->options['iTag']['endpoint'] . '?' . http_build_query($queryParams)), true); - $curl->close(); - } catch (Exception $e) { - $curl->close(); - RestoLogUtil::httpError($e->getCode(), $e->getMessage()); - } - - /* - * Return empty result - */ - if (!isset($iTagFeature) || !isset($iTagFeature['content'])) { - return array(); - } - - return $this->processITagKeywords($iTagFeature); - } - - /** - * Return all keywords from iTag process - * - * @param array $iTagFeature - */ - private function processITagKeywords($iTagFeature) - { - /* - * Initialize keywords array from faceted properties - */ - $keywords = array(); - - /* - * Process keywords - */ - foreach ($iTagFeature['content'] as $key => $value) { - switch ($key) { - case 'political': - if (isset($value['continents'])) { - $keywords = array_merge($keywords, $this->getGenericKeywords($value['continents'], array( - 'defaultName' => null, - 'parentId' => null - ))); - } - break; - - case 'landcover': - $keywords = array_merge($keywords, $this->getLandCoverKeywords($value)); - break; - - case 'population': - $keywords = array_merge($keywords, $this->getPopulationKeywords($value)); - break; - - case 'keywords': - $keywords = array_merge($keywords, $this->getAlwaysKeywords($value)); - break; - - default: - if (is_array($value)) { - $keywords = array_merge($keywords, $this->getGenericKeywords($value, array( - 'defaultName' => null, - 'parentId' => null - ))); - } - } - } - - return $keywords; - } - - /** - * Get keywords from iTag 'keywords' property - * - * @param array $properties - */ - private function getAlwaysKeywords($properties) - { - $keywords = array(); - foreach (array_values($properties) as $id) { - list($type, $normalized) = explode(RestoConstants::TAG_SEPARATOR, $id, 2); - if (!$this->alreadyExists($keywords, $id)) { - $keywords[] = array( - 'id' => $id, - 'name' => ucfirst($normalized), - 'type' => $type - ); - } - } - return $keywords; - } - - /** - * Get Landcover keywords - * - * @param array $properties - */ - private function getLandCoverKeywords($properties) - { - $keywords = array(); - - /* - * Main landcover - */ - if (isset($properties['main'])) { - foreach (array_values($properties['main']) as $landcover) { - $id = $landcover['id']; - list($type) = explode(RestoConstants::TAG_SEPARATOR, $landcover['id'], 1); - if (!$this->alreadyExists($keywords, $id)) { - $keywords[] = array( - 'id' => $id, - 'name' => $landcover['name'], - 'type' => $type, - 'area' => $landcover['area'], - 'value' => $landcover['pcover'] - ); - } - } - } - - return $keywords; - } - - /** - * Get generic keywords - * - * @param array $properties - * @param array $options - * @return array - */ - private function getGenericKeywords($properties, $options) - { - $keywords = array(); - for ($i = 0, $ii = count($properties); $i < $ii; $i++) { - $keyword = $this->getGenericKeyword($properties[$i], $options['defaultName'], $options['parentId']); - if (! $this->alreadyExists($keywords, $keyword['id'])) { - $keywords[] = $keyword; - switch ($keyword['type']) { - case 'continent': - $keywords = array_merge($keywords, $this->getGenericKeywords($properties[$i]['countries'], array( - 'parentId' => $keyword['id'], - 'defaultName' => null - ))); - break; - case 'country': - if (isset($properties[$i]['regions'])) { - $keywords = array_merge($keywords, $this->getGenericKeywords($properties[$i]['regions'], array( - 'parentId' => $keyword['id'], - 'defaultName' => '_all' - ))); - } - break; - case 'region': - $keywords = array_merge($keywords, $this->getGenericKeywords($properties[$i]['states'], array( - 'parentId' => $keyword['id'], - 'defaultName' => '_unknown' - ))); - break; - default: - break; - } - } - } - - return $keywords; - } - - /** - * Get generic keyword - * - * @param array $property - * @param string $defaultName - * @param string $parentId - * - */ - private function getGenericKeyword($property, $defaultName, $parentId) - { - $exploded = explode(RestoConstants::TAG_SEPARATOR, $property['id']); - - $keyword = array( - 'id' => $property['id'], - 'name' => $property['name'] ?? $defaultName, - 'type' => $exploded[0] - ); - if (isset($parentId)) { - $keyword['parentId'] = $parentId; - } - if (isset($property['area'])) { - $keyword['value'] = $property['area']; - } - if (isset($property['pcover'])) { - $keyword['value'] = $property['pcover']; - } - /* - * Absolute coverage of geographical entity - */ - if (isset($property['gcover'])) { - $keyword['gcover'] = $property['gcover']; - } - - return $keyword; - } - - /** - * Return a RESTo keywords array from feature properties - * - * @param array $properties - * @param RestoModel $model - */ - private function keywordsFromProperties($properties, $model) - { - /* - * [IMPORTANT] If input properties contains a _keywords property then use it - */ - $keywords = $properties['_keywords'] ?? array(); - - /* - * Roll over facet categories - */ - foreach (array_values($model->facetCategories) as $facetCategory) { - $keywords = array_merge($keywords, $this->keywordsFromFacets($properties, $facetCategory, $model)); - } - - /* - * Get date keywords - */ - return array_merge($keywords, $this->getDateKeywords($properties, $model)); - } - - /** - * Process keywords for facets - * - * @param array $properties - * @param array $facetCategory - * @param RestoModel $model - * @return array - */ - private function keywordsFromFacets($properties, $facetCategory, $model) - { - $parentId = null; - $keywords = array(); - for ($i = 0, $ii = count($facetCategory); $i < $ii; $i++) { - // Get value in properties for input facetCategory - $value = $properties[$facetCategory[$i]] ?? null; - - // If the facetCategory is not found, try the STAC alias - if (!isset($value) && isset($model->stacMapping[$facetCategory[$i]])) { - $value = $properties[$model->stacMapping[$facetCategory[$i]]['key']] ?? null; - } - - if (isset($value)) { - // If input property is an array then split into individuals values - // This is to support "instruments" for instance - // [IMPORTANT] Only a leaf (i.e. the last child) can be an array with more than 1 element - // otherwise parentId computation can be erroneous - if (! is_array($value)) { - $value = array($value); - } - - $newParentId = null; - for ($j = 0, $jj = count($value); $j < $jj; $j++) { - $keyword = array(); - // [IMPORTANT] Discard all spaces from value - $id = $facetCategory[$i] . RestoConstants::TAG_SEPARATOR . str_replace(' ', '', $value[$j]); - if (! $this->alreadyExists($keywords, $id)) { - $keyword = array( - 'id' => $id, - 'name' => $value[$j], - 'type' => $facetCategory[$i], - ); - if (isset($parentId)) { - $keyword['parentId'] = $parentId; - } - $keywords[] = $keyword; - $newParentId = $id; - } - } - if ($newParentId) { - $parentId = $id; - } - } else { - $parentId = null; - } - } - return $keywords; - } - - /** - * Process date keywords - * - * @param array $properties - * @param RestoModel $model - * @return array - */ - private function getDateKeywords($properties, $model) - { - - $startDate = $properties[$model->searchFilters['time:start']['key']]; - - /* - * No startDate property or both startDate and completionDate are presents => range is not supported - */ - if (! isset($startDate) || (isset($startDate) && isset($properties['completionDate']))) { - return array(); - } - - /* - * Year - */ - $year = substr($startDate, 0, 4); - - /* - * Month - */ - $month = substr($startDate, 5, 2); - - /* - * Day - */ - $day = substr($startDate, 8, 2); - - return array( - array( - 'id' => 'year' . RestoConstants::TAG_SEPARATOR . $year, - 'name' => $year, - 'type' => 'year' - ), - array( - 'id' => 'month' . RestoConstants::TAG_SEPARATOR . $month, - 'name' => $month, - 'type' => 'month' - ), - array( - 'id' => 'day' . RestoConstants::TAG_SEPARATOR . $day, - 'name' => $day, - 'type' => 'day' - ) - ); - } - - /** - * Get keywords from iTag 'population' property - * - * @param array $populationProperty - */ - private function getPopulationKeywords($populationProperty) - { - return array( - array( - 'id' => 'other:population', - 'name' => 'Population', - 'type' => 'other', - 'count' => $populationProperty['count'], - 'densityPerSquareKm' => $populationProperty['densityPerSquareKm'] - ) - ); - } - - /** - * Return true if id exists in keywords array - * - * @param array $keywords - * @param string $identifier - */ - private function alreadyExists($keywords, $identifier) - { - for ($i = count($keywords); $i--;) { - if ($identifier === $keywords[$i]['id']) { - return true; - } - } - return false; - } -} diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php new file mode 100755 index 00000000..424b712b --- /dev/null +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -0,0 +1,407 @@ +dbDriver = $dbDriver; + } + + /** + * Format catalog for output + * + * @param array $rawCatalog + */ + public static function format($rawCatalog) + { + return array( + 'id' => $rawCatalog['id'], + 'title' => $rawCatalog['title'], + 'description' => $rawCatalog['description'], + 'links' => isset($rawCatalog['links']) ? json_decode($rawCatalog['links'], true) : array(), + 'level' => (integer) $rawCatalog['level'], + 'counters' => isset($rawCatalog['counters']) ? json_decode($rawCatalog['counters'], true) : null, + 'owner' => $rawCatalog['owner'] ?? null, + 'visibility' => (integer) $rawCatalog['visibility'], + 'created' => $rawCatalog['created'], + 'rtype' => $rawCatalog['rtype'] ?? null, + 'hashtag' => $rawCatalog['hashtag'] ?? null + ); + } + + /** + * Get catalogs (and eventually all its childs if id is set) + * + * @param array $params + * @param boolean $withChilds + */ + public function getCatalogs($params, $withChilds = false) + { + + $catalogs = array(); + $where = array(); + $values = array(); + $params = isset($params) ? $params : array(); + + if ( isset($params['id']) ) { + if ($withChilds) { + $values[] = $params['id'] . '%'; + $where[] = 'public.normalize(id) LIKE public.normalize(' . count($values) . ')'; + + } + else { + $values[] = $params['id']; + $where[] = 'public.normalize(id) = public.normalize(' . count($values) . ')'; + } + } + if ( isset($params['description']) ) { + $values[] = '%' . $params['description'] . '%'; + $where[] = 'public.normalize(description) LIKE public.normalize(' . count($values) . ')'; + } + + // Direct where clause + if ( isset($params['where'])) { + $where[] = $params['where']; + } + + $results = $this->dbDriver->pQuery('SELECT id, title, description, level, counters, owner, links, visibility, rtype, hashtag, to_iso8601(created) as created FROM ' . $this->dbDriver->targetSchema . '.catalog' . ( empty($where) ? '' : ' WHERE ' . join(' AND ', $where) . ' ORDER BY id ASC'), $values); + while ($result = pg_fetch_assoc($results)) { + $catalogs[] = CatalogsFunctions::format($result); + } + + return $catalogs; + } + + /** + * Store catalogs within database + * + * !! THIS FUNCTION IS THREAD SAFE !! + * + * @param array $catalogs + * @param string $userid + * @param string $collectionId + */ + public function storeCatalogs($catalogs, $userid, $collectionId) + { + // Empty facets - do nothing + if (!isset($catalogs) || count($catalogs) === 0) { + return; + } + + $counters = array( + 'total' => 1, + 'collections' => array() + ); + + if (isset($collectionId)) { + $counters['collections'][$collectionId] = 1; + } + + for ($i = count($catalogs); $i--;) { + + $catalog = $catalogs[$i]; + + /* + * Thread safe ingestion using upsert - guarantees that counter is correctly incremented during concurrent transactions + */ + $insert = 'INSERT INTO ' . $this->dbDriver->targetSchema . '.catalog (id, title, description, level, counters, owner, links, visibility, rtype, hashtag, created) SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,now()'; + $upsert = 'UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET counters=public.increment_counters(counters, 1, ' . (isset($collectionId) ? '\'' . $collectionId . '\'' : 'NULL') . ') WHERE public.normalize(id)=public.normalize($1)'; + $this->dbDriver->pQuery('WITH upsert AS (' . $upsert . ' RETURNING *) ' . $insert . ' WHERE NOT EXISTS (SELECT * FROM upsert)', array( + $catalog['id'], + $catalog['title'] ?? $catalog['id'], + $catalog['description'] ?? null, + $this->getLevel($catalog['id']), + // If no input counter is specified - set to 1 + json_encode($counters, JSON_UNESCAPED_SLASHES), + $catalog['owner'] ?? $userid, + isset($catalog['links']) ? json_encode($catalog['links'], JSON_UNESCAPED_SLASHES) : null, + RestoConstants::GROUP_DEFAULT_ID, + $catalog['rtype'] ?? null, + $catalog['hashtag'] ?? null + ), 500, 'Cannot insert catalog ' . $catalog['id']); + } + } + + /** + * Update catalog + * + * @param array $catalog + * @return integer // number of catalogs updated + */ + public function updateCatalog($catalog) + { + + $values = array( + $catalog['id'] + ); + + $canBeUpdated = array( + 'title', + 'owner', + 'description', + 'links', + 'visibility' + ); + + $set = array(); + foreach (array_keys($catalog) as $key ) { + if (in_array($key, $canBeUpdated)) { + $values[] = $catalog[$key]; + $set[] = $key . '=$' . count($values); + } + } + + if ( empty($set) ) { + return array( + 'catalogsUpdated' => 0 + ); + } + + $results = $this->dbDriver->fetch($this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET ' . join(',', $set) . ' WHERE public.normalize(id)=public.normalize($1) RETURNING id', $values, 500, 'Cannot update facet ' . $catalog['id'])); + + return array( + 'catalogsUpdated' => count($results) + ); + + } + + /** + * Remove catalog from id - can only works if catalog has no child + * + * @param string $catalogId + */ + public function removeCatalog($catalogId) + { + + $results = $this->dbDriver->fetch($this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.catalog WHERE public.normalize(id)=public.normalize($1) RETURNING id', array($catalogId), 500, 'Cannot delete catalog' . $catalogId)); + $catalogsDeleted = count($results); + + // Next remove the catalog entry from all features + $query = join(' ', array( + 'UPDATE ' . $this->dbDriver->targetSchema . '.feature SET', + 'hashtags=ARRAY_REMOVE(hashtags, $1),normalized_hashtags=ARRAY_REMOVE(normalized_hashtags,public.normalize($1)),', + 'keywords=(SELECT json_agg(e) FROM json_array_elements(keywords) AS e WHERE e->>\'id\' <> $1)', + 'WHERE normalized_hashtags @> public.normalize_array(ARRAY[$1]) RETURNING id' + ) + ); + $results = $this->dbDriver->fetch($this->dbDriver->pQuery($query, array($catalogId), 500, 'Cannot update features' . $catalogId)); + + return array( + 'catalogDeleted' => $catalogsDeleted, + 'featuresUpdated' => count($results) + ); + + } + + /** + * Return STAC Summaries from catalogs elements from a type for a given collection + * + * Returned array of array indexed by collection id + * + * array( + * 'collection1' => array( + * 'type#' => array( + * 'value1' => count1, + * 'value2' => count2, + * 'parent' => array( + * 'value3' => count3, + * ... + * ) + * ... + * ), + * 'type2' => array( + * ... + * ), + * ... + * ), + * 'collection2' => array( + * 'type#' => array( + * 'value1' => count1, + * 'value2' => count2, + * 'parent' => array( + * 'value3' => count3, + * ... + * ) + * ... + * ), + * 'type2' => array( + * ... + * ), + * ... + * ) + * ) + * + * @param array $types + * @param string $collectionId + * + * @return array + */ + public function getSummaries($types, $collectionId) + { + + $summaries = array(); + + $pivots = array(); + + $catalogs = $this->getCatalogs(array( + 'where' => !empty($types) ? 'rtype IN(\'' . join('\',\'', $types) . '\')' : 'rtype NOT IN (\'' . join('\',\'', CatalogsFunctions::TOPONYM_TYPES) . '\')' + )); + + $counter = 0; + for ($i = 0, $ii = count($catalogs); $i < $ii; $i++) { + + // Discard level 1 catalog and empty one + if ( $catalogs[$i]['level'] === 1 || !isset($catalogs[$i]['counters']) || $catalogs[$i]['counters']['total'] === 0 ) { + continue; + } + + // Collection is set => get only catalogs with collectionId counter > 0 + // Otherwise get only catalogs with total counter > 0 + if ( isset($collectionId) ) { + if ( !isset($catalogs[$i]['counters']['collections'][$collectionId]) || $catalogs[$i]['counters']['collections'][$collectionId] === 0 ) { + continue; + } + $counter = $catalogs[$i]['counters']['collections'][$collectionId]; + } + else { + $collectionId = '*'; + $counter = $catalogs[$i]['counters']['total']; + } + + if ( !isset($pivots[$collectionId]) ) { + $pivots[$collectionId] = array(); + } + + $type = $catalogs[$i]['rtype']; + + if (!isset($pivots[$collectionId][$type])) { + $pivots[$collectionId][$type] = array(); + } + + $create = true; + + // Constant is the last part of the id url + $exploded = explode('/', $catalogs[$i]['id']); + $const = array_pop($exploded); + for ($j = count($pivots[$collectionId][$type]); $j--;) { + if (isset($pivots[$collectionId][$type][$j]['const'])) { + if ($pivots[$collectionId][$type][$j]['const'] === $const) { + $pivots[$collectionId][$type][$j]['count'] += $counter; + $create = false; + break; + } + } + } + + if ($create) { + $newPivot = array( + 'const' => $const, + 'count' => $counter + ); + + if ($catalogs[$i]['title'] !== $newPivot['const']) { + $newPivot['title'] = $catalogs[$i]['title']; + } + $pivots[$collectionId][$type][] = $newPivot; + } + } + + foreach (array_keys($pivots) as $_collectionId) { + if ( !isset($summaries[$_collectionId]) ) { + $summaries[$_collectionId] = array(); + } + foreach (array_keys($pivots[$_collectionId]) as $key) { + if (count($pivots[$_collectionId][$key]) === 1) { + $summaries[$_collectionId][$key] = array_merge($pivots[$_collectionId][$key][0], array('type' => 'string')); + } else { + $summaries[$_collectionId][$key] = array( + 'type' => 'string', + 'oneOf' => $pivots[$_collectionId][$key] + ); + } + } + } + + return $summaries; + + } + + /** + * Remove feature facets from database + * + * @param array $hashtags + * @param string $collectionId + */ + public function removeFacetsFromHashtags($hashtags, $collectionId) + { + for ($i = count($hashtags); $i--;) { + $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.facet SET counter = GREATEST(0, counter - 1) WHERE public.normalize(id)=public.normalize($1) AND (public.normalize(collection)=public.normalize($2) OR public.normalize(collection)=\'*\')', array($hashtags[$i], $collectionId), 500, 'Cannot delete facet for ' . $collectionId); + } + } + + /** + * Return the number of levels (i.e. number of /) from a catalog id. + * For instance 'continents/America/USA/NewYork' has a level of 4 + * + * @param string $id + * @return integer + */ + private function getLevel($id) + { + if ( !isset($id) ) { + return 0; + } + return count(explode('/', $id)); + } + +} diff --git a/app/resto/core/dbfunctions/FeaturesFunctions.php b/app/resto/core/dbfunctions/FeaturesFunctions.php index 2f2f2cdc..46d74420 100755 --- a/app/resto/core/dbfunctions/FeaturesFunctions.php +++ b/app/resto/core/dbfunctions/FeaturesFunctions.php @@ -300,7 +300,7 @@ public function storeFeature($id, $collection, $featureArray) 'geometry' => $featureArray['topologyAnalysis']['geometry'] ?? null, 'centroid' => $featureArray['topologyAnalysis']['centroid'] ?? null, 'geom' => $featureArray['topologyAnalysis']['geom'] ?? null - ), + ), array( 'productIdentifier', 'title', @@ -309,20 +309,17 @@ public function storeFeature($id, $collection, $featureArray) 'completionDate' ) ); - + /* - * Eventually a catalog can be created as facet during feature ingestion. + * Eventually an 'isExternal' catalog can be created during feature ingestion. * Check that user can create catalog */ - $facetsStored = $collection->context->core['storeFacets'] && $collection->model->dbParams['storeFacets']; - if ($facetsStored) { - foreach (array_values($keysValues['facets']) as $facetElement) { - if (isset($facetElement['type']) && $facetElement['type'] === 'catalog') { - if ( !$collection->user->hasRightsTo(RestoUser::CREATE_CATALOG) ) { - return RestoLogUtil::httpError(403, 'Feature ingestion leads to creation of catalog ' . $facetElement['id'] . ' but you don\'t have right to create catalogs'); - } - break; + foreach (array_values($keysValues['catalogs']) as $catalog) { + if (isset($catalog['isExternal']) && $catalog['isExternal']) { + if ( !$collection->user->hasRightsTo(RestoUser::CREATE_CATALOG) ) { + return RestoLogUtil::httpError(403, 'Feature ingestion leads to creation of catalog ' . $catalog['id'] . ' but you don\'t have right to create catalogs'); } + break; } } @@ -354,26 +351,26 @@ public function storeFeature($id, $collection, $featureArray) * Commit everything - rollback if one of the inserts failed */ pg_query($dbh, 'COMMIT'); + } catch (Exception $e) { pg_query($dbh, 'ROLLBACK'); RestoLogUtil::httpError(500, 'Feature ' . ($featureArray['productIdentifier'] ?? '') . ' cannot be inserted in database'); } /* - * Store facets outside of the transaction because error should not block feature ingestion + * Store catalogs outside of the transaction because error should not block feature ingestion */ - if ($facetsStored) { - try { - (new FacetsFunctions($this->dbDriver))->storeFacets($keysValues['facets'], $collection->user->profile['id'], $collection->id); - } catch (Exception $e) { - $facetsStored = false; - } + $catalogsStored = true; + try { + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysValues['catalogs'], $collection->user->profile['id'], $collection->id); + } catch (Exception $e) { + $catalogsStored = false; } - + return array( 'id' => $result['id'], 'productIdentifier' => $result['productidentifier'] ?? null, - 'facetsStored' => $facetsStored + 'catalogsStored' => $catalogsStored ); } @@ -576,6 +573,9 @@ public function updateFeatureProperty($feature, $property, $value) */ public function updateFeatureDescription($feature, $description) { + + return 'TODO'; + // Get hashtags to remove from feature before update $hashtagsToRemove = $this->extractHashtagsFromText($feature->toArray()['properties']['description'], true); $hashtagsToAdd = $this->extractHashtagsFromText($description, true); @@ -661,54 +661,6 @@ public function getCount($from, $filters = array()) ); } - /** - * Return array of hashtags from a text - invalid characters are discarded - * - * [WARNING] The leading '#' is not returned - * - * Example: - * - * $text = "This is a #test #withA!%.badhashtag" - * - * returns: - * - * array('test', 'withAbadhashtag') - * - * @param string $text - * @param boolean $stringOnly - * - * @return array - */ - public function extractHashtagsFromText($text, $stringOnly) - { - $matches = null; - if (isset($text)) { - preg_match_all("/#([^ ]+)/u", $text, $matches); - if ($matches) { - $hashtagsArray = array_count_values($matches[1]); - $hashtags = array(); - foreach (array_keys($hashtagsArray) as $key) { - # Detect special hashtags i.e. with prefix - $exploded = explode(RestoConstants::TAG_SEPARATOR, $key); - if (!$stringOnly && count($exploded) > 1) { - $type = array_shift($exploded); - $hashtags[] = array( - 'id' => $key, - 'value' => join(RestoConstants::TAG_SEPARATOR, $exploded), - 'type' => $type, - // Special case for catalog => force collection to all - 'collection' => $type === 'catalog' ? '*' : null - ); - } else { - $hashtags[] = RestoUtil::cleanHashtag($key); - } - } - return $hashtags; - } - } - return array(); - } - /** * Store feature additional content * @@ -758,14 +710,15 @@ private function featureArrayToKeysValues($collection, $featureArray, $protected $output = array( 'keysAndValues' => array(), 'params' => array(), - 'facets' => null, - 'modelTables' => array() + 'modelTables' => array(), + 'catalogs' => $featureArray['catalogs'] ); /* * Roll over properties */ foreach ($featureArray['properties'] as $propertyName => $propertyValue) { + /* * Do not process null values and protected values */ @@ -787,36 +740,12 @@ private function featureArrayToKeysValues($collection, $featureArray, $protected } } - /* - * Keywords - */ - elseif ($propertyName === 'keywords' && is_array($propertyValue)) { - $facetsFunctions = new FacetsFunctions($this->dbDriver); - - // Initialize keywords - $keysAndValues['keywords'] = json_encode($propertyValue, JSON_UNESCAPED_SLASHES); - - // Compute facets - $output['facets'] = array_merge($facetsFunctions->getFacetsFromKeywords($propertyValue, $collection->model->facetCategories, $collection->id), $this->extractHashtagsFromText($featureArray['properties']['description'] ?? '', false)); - - // Compute hashtags - $hashtags = $facetsFunctions->getHashtagsFromFacets($output['facets']); - if (count($hashtags) > 0) { - $keysAndValues['hashtags'] = '{' . join(',', $hashtags) . '}'; - $keysAndValues['normalized_hashtags'] = $keysAndValues['hashtags']; - } - - // Special content for LandCoverModel (i.e. itag keys) - if (count($collection->model->tables) > 0 && $collection->model->tables[0]['name'] == 'feature_landcover') { - $output['modelTables']['feature_landcover'] = $this->getITagColumnFromKeywords($propertyValue, $collection->model->tables[0]['columns']); - } - } - /* * Directly add to metadata */ else { - if (!isset($keysAndValues['metadata'])) { + + if ( !isset($keysAndValues['metadata']) ) { $keysAndValues['metadata'] = array(); } $keysAndValues['metadata'][$propertyName] = $propertyValue; @@ -837,9 +766,45 @@ private function featureArrayToKeysValues($collection, $featureArray, $protected } } + /* + * Catalogs + */ + $hashtags = array(); + for ($i = count($output['catalogs']); $i--;) { + + // Append additionnal properties + if ( isset($output['catalogs'][$i]['properties']) ) { + if ( !isset($keysAndValues['metadata']) ) { + $keysAndValues['metadata'] = array(); + } + $keysAndValues['metadata'] = array_merge($keysAndValues['metadata'], $output['catalogs'][$i]['properties']); + } + + // Special content for LandCoverModel (i.e. itag keys) + for ($j = 0, $jj = count($collection->model->tables); $j < $jj; $j++) { + if ($collection->model->tables[$j]['name'] == 'feature_landcover') { + $columns = $this->getITagColumnFromCatalogs($output['catalogs'][$i], $collection->model->tables[$j]['columns']); + if ( !empty($columns) ) { + $output['modelTables']['feature_landcover'] = $columns; + } + break; + } + } + + if ( isset($output['catalogs'][$i]['hashtag']) ) { + $hashtags[] = $output['catalogs'][$i]['hashtag']; + } + + } + + if ( !empty($hashtags) ) { + $keysAndValues['hashtags'] = '{' . join(',', $hashtags) . '}'; + $keysAndValues['normalized_hashtags'] = $keysAndValues['hashtags']; + } + // JSON encode metadata $keysAndValues['metadata'] = isset($keysAndValues['metadata']) ? json_encode($keysAndValues['metadata'], JSON_UNESCAPED_SLASHES) : null; - + $counter = 0; $output['keysAndValues'] = array_merge($protected, $keysAndValues); foreach (array_keys($output['keysAndValues'] ?? array()) as $key) { @@ -856,18 +821,18 @@ private function featureArrayToKeysValues($collection, $featureArray, $protected } /** - * Get database DefaultModel columns from input keywords + * Get iTag column name from input catalogs * - * @param array $keywords + * @param array $catalogs * @param array $tableColumns * @return array */ - private function getITagColumnFromKeywords($keywords, $tableColumns) + private function getITagColumnFromCatalogs($catalogs, $tableColumns) { $columns = array(); - foreach (array_values($keywords) as $keyword) { - if (in_array(strtolower($keyword['name']), $tableColumns)) { - $columns[strtolower($keyword['name'])] = $keyword['value']; + for ($i = 0, $ii = count($catalogs); $i < $ii; $i++) { + if (isset($catalogs[$i]['title']) && in_array(strtolower($catalogs[$i]['title']), $tableColumns)) { + $columns[strtolower($catalogs[$i]['title'])] = $catalogs[$i]['properties']['pcover']; } } return $columns; diff --git a/app/resto/core/models/LandCoverModel.php b/app/resto/core/models/LandCoverModel.php index e14d3b46..92260f1b 100755 --- a/app/resto/core/models/LandCoverModel.php +++ b/app/resto/core/models/LandCoverModel.php @@ -38,7 +38,7 @@ public function __construct($options = array()) /* * Extend search filters */ - if (isset($this->options['addons']['Tag']) && $this->options['addons']['Tag']['options']['iTag']['addSearchFilters']) { + if (isset($this->options['addons']['Cataloger']) && $this->options['addons']['Cataloger']['options']['iTag']['addSearchFilters']) { $this->searchFilters = array_merge($this->searchFilters, array( 'resto:cultivatedCover' => array( diff --git a/build/resto/config.php.template b/build/resto/config.php.template index de3dc932..7e77676c 100755 --- a/build/resto/config.php.template +++ b/build/resto/config.php.template @@ -93,7 +93,7 @@ return array( 'minMatch' => ${ADDON_STAC_MINMATCH:-0} ) ), - 'Tag' => array( + 'Cataloger' => array( 'options' => array( 'iTag' => array( 'endpoint' => '${ADDON_TAG_ITAG_ENDPOINT}', diff --git a/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040.json b/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040.json index 54f2f7c3..ea4cb53f 100644 --- a/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040.json +++ b/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040.json @@ -51,6 +51,7 @@ ] }, "properties": { + "description": "This is to show that description can contains hashtags #example1 #example2. Hashtags are extracted and converted into catalogs.", "authority": "ESA", "startDate": "2019-06-11T16:11:41.808Z", "productType": "REFLECTANCE", @@ -68,7 +69,14 @@ "view:sun_elevation": 52.7900601006242, "view:sun_azimuth": 199.076369728221, "view:incidence_angle": 7.67887190207369, - "view:azimuth": 312.400523076109 + "view:azimuth": 312.400523076109, + "resto:catalogs":[ + { + "id":"S2TestCatalog", + "title":"S2TestCatalog", + "description":"This is a test catalog defined within a feature - during feature ingestion, this catalog should be created within resto and the feature associated to it" + } + ] }, "assets": { "thumbnail": { diff --git a/resto-database-model/01_resto_functions.sql b/resto-database-model/01_resto_functions.sql index 32b59721..80976f3a 100644 --- a/resto-database-model/01_resto_functions.sql +++ b/resto-database-model/01_resto_functions.sql @@ -248,3 +248,93 @@ BEGIN RETURN to_char(ts, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'); END; $$ LANGUAGE PLPGSQL IMMUTABLE; + +-- +-- +-- SYNOPSYS: +-- increment_counters(counters JSON, value INTEGER, collection_id TEXT) +-- +-- DESCRIPTION: +-- Increment JSON counters column +-- counters column: +-- { +-- "total": 12, +-- "collections":{ +-- "S2":11, +-- "L8":1 +-- } +-- } +-- value : 1, +-- collection_id: 'S2' +-- +-- will return +-- +-- { +-- "total": 13, +-- "collections":{ +-- "S2":12, +-- "L8":1 +-- } +-- } +-- +-- USAGE: +-- SELECT increment_counters('{}', 1, 'S2'); +-- +CREATE OR REPLACE FUNCTION public.increment_counters( + counters JSON, + increment INTEGER, + collection_id TEXT +) +RETURNS JSON AS $$ +DECLARE + -- Variable to store the total property from JSON + total INTEGER; + -- Variable to store individual keys and values from the collections array + collection_key TEXT; + collection_value INTEGER; + -- Variable to store the updated collections array + updated_collections JSONB; + -- Variable to store the updated JSON object + updated_counters JSONB; + -- Boolean to check if collection_id exists + collection_exists BOOLEAN := FALSE; +BEGIN + -- Extract the total property from the JSON object + total := (counters->>'total')::INTEGER; + + -- Increment the total by the value + total := total + increment; + + -- Initialize the updated collections as the original collections + updated_collections := counters->'collections'; + + -- Loop through the collections array and update the collection_id value + FOR collection_key, collection_value IN + SELECT key, increment::INTEGER + FROM json_each_text(counters->'collections') + LOOP + IF collection_key = collection_id THEN + -- Increment the collection value by the input value + collection_value := collection_value + increment; + -- Update the collections JSONB with the new value + updated_collections := jsonb_set(updated_collections, ARRAY[collection_key], to_jsonb(collection_value)); + collection_exists := TRUE; + END IF; + END LOOP; + + -- If the collection_id does not exist, add it with the input value + IF NOT collection_exists THEN + updated_collections := jsonb_set(updated_collections, ARRAY[collection_id], to_jsonb(increment)); + END IF; + + -- Update the JSON object with the new total and collections + updated_counters := jsonb_set(counters::jsonb, '{total}', to_jsonb(total)); + updated_counters := jsonb_set(updated_counters, '{collections}', updated_collections); + + -- Example operation: Print the updated JSON object for debugging + RAISE NOTICE 'Updated JSON: %', updated_counters::TEXT; + + -- Return the updated JSON object + RETURN updated_counters::JSON; +END; +$$ LANGUAGE plpgsql; diff --git a/resto-database-model/03_resto_target_model.sql b/resto-database-model/03_resto_target_model.sql index 8f68425d..24c1277a 100644 --- a/resto-database-model/03_resto_target_model.sql +++ b/resto-database-model/03_resto_target_model.sql @@ -411,6 +411,67 @@ CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.facet ( ); +-- +-- Catalog table +-- +CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.catalog ( + + -- + -- The id must be a path starting with '/' (e.g. '/geographical/Europe/France/Haute-Garonne/Toulouse) + -- Note: only the last part is displayed in JSON output (e.g. Toulouse) + -- + id TEXT PRIMARY KEY, + + -- A short descriptive one-line title for the Catalog. + title TEXT, + + -- Detailed multi-line description to fully explain the Catalog. CommonMark 0.29 syntax MAY be used for rich text representation. + description TEXT, + + -- Number of levels for this catalog + level INTEGER, + + -- Number of items within this catalog + counter INTEGER, + + -- Number of items within this catalog (total and per collection) + counters JSON, + + -- Owner of the catalog (i.e. the one who creates it) + owner BIGINT, + + -- Catalog date of creation + created TIMESTAMP DEFAULT now(), + + -- A list of references to other documents. + links JSON, + + -- Visibility - group visibility (only user within this group can see collection) + visibility INTEGER, + + -- resto type + rtype TEXT, + + -- Hashtag to find features within this catalog + hashtag TEXT + +); + +-- +-- Feature/Catalog association +-- +CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.feature_catalog ( + + -- Reference __DATABASE_TARGET_SCHEMA__.feature.id + featureid UUID NOT NULL REFERENCES __DATABASE_TARGET_SCHEMA__.feature (id) ON DELETE CASCADE, + + -- Reference __DATABASE_TARGET_SCHEMA__.catalog.id + catalogid TEXT NOT NULL REFERENCES __DATABASE_TARGET_SCHEMA__.catalog (id) ON DELETE CASCADE, + + PRIMARY KEY (featureid, catalogid) + +); + -- --------------------- INDEXES --------------------------- -- [TABLE __DATABASE_TARGET_SCHEMA__.collection] @@ -434,6 +495,10 @@ CREATE INDEX IF NOT EXISTS idx_type_facet ON __DATABASE_TARGET_SCHEMA__.facet (t CREATE INDEX IF NOT EXISTS idx_collection_facet ON __DATABASE_TARGET_SCHEMA__.facet (public.normalize(collection)); CREATE INDEX IF NOT EXISTS idx_value_facet ON __DATABASE_TARGET_SCHEMA__.facet USING GIN (public.normalize(value) gin_trgm_ops); +-- [TABLE __DATABASE_TARGET_SCHEMA__.catalog] +CREATE INDEX IF NOT EXISTS idx_id_catalog ON __DATABASE_TARGET_SCHEMA__.catalog (public.normalize(id)); +CREATE INDEX IF NOT EXISTS idx_description_catalog ON __DATABASE_TARGET_SCHEMA__.catalog USING GIN (public.normalize(description) gin_trgm_ops); + -- [TABLE __DATABASE_TARGET_SCHEMA__.feature] CREATE INDEX IF NOT EXISTS idx_collection_feature ON __DATABASE_TARGET_SCHEMA__.feature USING btree (collection); CREATE INDEX IF NOT EXISTS idx_startdateidx_feature ON __DATABASE_TARGET_SCHEMA__.feature USING btree (startdate_idx); From 64f58f2ad9cfd89d7bd2d26dd452c685678f8ec9 Mon Sep 17 00:00:00 2001 From: jjrom Date: Thu, 6 Jun 2024 13:41:53 +0200 Subject: [PATCH 07/27] removeFeature works with catalogs --- app/resto/core/RestoDatabaseDriver.php | 2 +- app/resto/core/RestoModel.php | 6 +- app/resto/core/api/FeaturesAPI.php | 2 +- .../core/dbfunctions/CatalogsFunctions.php | 36 ++++++++++- .../core/dbfunctions/FeaturesFunctions.php | 64 +++++++++++-------- .../cont-init.d/17-resto-database.php | 7 +- resto-database-model/01_resto_functions.sql | 61 +++++++++++++++++- .../03_resto_target_model.sql | 8 ++- resto-database-model/04_resto_triggers.sql | 5 +- 9 files changed, 151 insertions(+), 40 deletions(-) diff --git a/app/resto/core/RestoDatabaseDriver.php b/app/resto/core/RestoDatabaseDriver.php index 83aa63ac..26b952a3 100755 --- a/app/resto/core/RestoDatabaseDriver.php +++ b/app/resto/core/RestoDatabaseDriver.php @@ -183,7 +183,7 @@ public function pQuery($query, $params, $errorCode = 500, $errorMessage = null) * Convert database query result into array * * @param PgSql\Result $results - * @return array + * @return Array */ public function fetch($results) { diff --git a/app/resto/core/RestoModel.php b/app/resto/core/RestoModel.php index f48dcb0a..b3d8bc99 100755 --- a/app/resto/core/RestoModel.php +++ b/app/resto/core/RestoModel.php @@ -444,8 +444,7 @@ public function storeFeatures($collection, $body, $params) if ( isset($insert['result']) ) { $featuresInserted[] = array( 'featureId' => $insert['result']['id'], - 'productIdentifier' => $insert['result']['productIdentifier'], - 'catalogsStored' => $insert['result']['catalogsStored'] + 'productIdentifier' => $insert['result']['productIdentifier'] ); $dates[] = isset($insert['featureArray']['properties']) && isset($insert['featureArray']['properties']['startDate']) ? $insert['featureArray']['properties']['startDate'] : null; @@ -461,8 +460,7 @@ public function storeFeatures($collection, $body, $params) if ($insert['result'] !== false) { $featuresInserted[] = array( 'featureId' => $insert['result']['id'], - 'productIdentifier' => $insert['result']['productIdentifier'], - 'catalogsStored' => $insert['result']['catalogsStored'] + 'productIdentifier' => $insert['result']['productIdentifier'] ); $dates[] = isset($insert['featureArray']['properties']) && isset($insert['featureArray']['properties']['startDate']) ? $insert['featureArray']['properties']['startDate'] : null; diff --git a/app/resto/core/api/FeaturesAPI.php b/app/resto/core/api/FeaturesAPI.php index 9da28e2e..f35e087a 100755 --- a/app/resto/core/api/FeaturesAPI.php +++ b/app/resto/core/api/FeaturesAPI.php @@ -922,7 +922,7 @@ public function deleteFeature($params) return RestoLogUtil::success('Feature deleted', array( 'featureId' => $feature->id, - 'facetsDeleted' => $result['facetsDeleted'] + 'catalogsUpdated' => $result['catalogsUpdated'] )); } } diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 424b712b..224c5c4e 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -130,8 +130,9 @@ public function getCatalogs($params, $withChilds = false) * @param array $catalogs * @param string $userid * @param string $collectionId + * @param string $featureId */ - public function storeCatalogs($catalogs, $userid, $collectionId) + public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) { // Empty facets - do nothing if (!isset($catalogs) || count($catalogs) === 0) { @@ -169,6 +170,16 @@ public function storeCatalogs($catalogs, $userid, $collectionId) $catalog['rtype'] ?? null, $catalog['hashtag'] ?? null ), 500, 'Cannot insert catalog ' . $catalog['id']); + + // Feature is set => fill catalog_feature table + if ( isset($featureId) && isset($catalog['hashtag']) ) { + $this->dbDriver->pQuery('INSERT INTO ' . $this->dbDriver->targetSchema . '.catalog_feature (featureid, catalogid, hashtag) VALUES ($1,$2,$3) ON CONFLICT (featureid, catalogid) DO NOTHING', array( + $featureId, + $catalog['id'], + $catalog['hashtag'] + ), 500, 'Cannot catalog_feature association ' . $catalog['id'] . '/' . $featureId); + } + } } @@ -215,6 +226,29 @@ public function updateCatalog($catalog) } + /** + * Increment all catalogs relied to feature + * + * @param string $featureId + * @param string $collectionId + * @param integer $increment + */ + public function updateCountsForFeature($featureId, $collectionId, $increment) + { + + $query = join(' ', array( + 'UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET counters=public.increment_counters(c.counters,' . $increment . ',' . (isset($collectionId) ? '\'' . $collectionId . '\'': 'NULL') . ')', + 'FROM ' . $this->dbDriver->targetSchema . '.catalog c,' . $this->dbDriver->targetSchema . '.catalog_feature cf', + 'WHERE c.id = cf.catalogid AND cf.featureid=\'' . pg_escape_string($this->dbDriver->getConnection(), $featureId) . '\' RETURNING c.id' + )); + + $results = $this->dbDriver->fetch($this->dbDriver->query($query)); + + return count($results); + + } + + /** * Remove catalog from id - can only works if catalog has no child * diff --git a/app/resto/core/dbfunctions/FeaturesFunctions.php b/app/resto/core/dbfunctions/FeaturesFunctions.php index 46d74420..355a539c 100755 --- a/app/resto/core/dbfunctions/FeaturesFunctions.php +++ b/app/resto/core/dbfunctions/FeaturesFunctions.php @@ -347,6 +347,11 @@ public function storeFeature($id, $collection, $featureArray) */ $this->storeFeatureAdditionalContent($result['id'], $collection->id, $keysValues['modelTables']); + /* + * Store catalogs + */ + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysValues['catalogs'], $collection->user->profile['id'], $collection->id, $result['id']); + /* * Commit everything - rollback if one of the inserts failed */ @@ -357,21 +362,11 @@ public function storeFeature($id, $collection, $featureArray) RestoLogUtil::httpError(500, 'Feature ' . ($featureArray['productIdentifier'] ?? '') . ' cannot be inserted in database'); } - /* - * Store catalogs outside of the transaction because error should not block feature ingestion - */ - $catalogsStored = true; - try { - (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysValues['catalogs'], $collection->user->profile['id'], $collection->id); - } catch (Exception $e) { - $catalogsStored = false; - } - return array( 'id' => $result['id'], - 'productIdentifier' => $result['productidentifier'] ?? null, - 'catalogsStored' => $catalogsStored + 'productIdentifier' => $result['productidentifier'] ?? null ); + } /** @@ -389,27 +384,42 @@ public function removeFeature($feature) * Remove feature */ try { - $result = pg_fetch_assoc($this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix'] . 'feature WHERE id=$1 RETURNING id', array($feature->id))); - if (empty($result)) { - return RestoLogUtil::httpError(404); - } + /* + * Get connection + */ + $dbh = $this->dbDriver->getConnection(); - } catch (Exception $e) { - return RestoLogUtil::httpError(500, 'Cannot delete feature ' . $feature->id); - } + /* + * Start transaction + */ + pg_query($dbh, 'BEGIN'); + + /* + * Update statistics counter for featureId - i.e. remove 1 per catalogs containing this feature + */ + $catalogsUpdated = (new CatalogsFunctions($this->dbDriver))->updateCountsForFeature($feature->id, $feature->collection->id, -1); - /* - * Remove facets - error is non blocking - */ - $facetsDeleted = true; - try { - (new FacetsFunctions($this->dbDriver))->removeFacetsFromHashtags($featureArray['properties']['hashtags'] ?? array(), $featureArray['collection']); + /* + * Next remove + */ + $result = pg_fetch_assoc($this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix'] . 'feature WHERE id=$1 RETURNING id', array($feature->id))); + + /* + * Commit everything - rollback if one of the inserts failed + */ + pg_query($dbh, 'COMMIT'); + } catch (Exception $e) { - $facetsDeleted = false; + pg_query($dbh, 'ROLLBACK'); + RestoLogUtil::httpError(500, 'Feature ' . ($featureArray['productIdentifier'] ?? $feature->id) . ' cannot be removed from database'); } + if (empty($result)) { + return RestoLogUtil::httpError(404); + } + return array( - 'facetsDeleted' => $facetsDeleted + 'catalogsUpdated' => $catalogsUpdated ); } diff --git a/build/resto/container_root/cont-init.d/17-resto-database.php b/build/resto/container_root/cont-init.d/17-resto-database.php index a9438bcc..a1ad4b37 100755 --- a/build/resto/container_root/cont-init.d/17-resto-database.php +++ b/build/resto/container_root/cont-init.d/17-resto-database.php @@ -25,7 +25,12 @@ $sqlFiles = glob('/resto-database-model/*.sql'); for ($i = 0, $ii = count($sqlFiles); $i < $ii; $i++) { $dbDriver->query(str_replace($replace, $with, file_get_contents($sqlFiles[$i]))); - } + } + + // Create trigger for geometry_part + if ( isset($config['database']['useGeometryPart']) && $config['database']['useGeometryPart'] ) { + $dbDriver->query(str_replace($replace, $with, 'CREATE TRIGGER update_geometry_part AFTER INSERT ON __DATABASE_TARGET_SCHEMA__.feature FOR EACH ROW EXECUTE PROCEDURE __DATABASE_TARGET_SCHEMA__.trigger_store_geometry_part();')); + } // Handle migrations scripts $sqlFiles = glob('/resto-database-model/migrations/*.sql'); diff --git a/resto-database-model/01_resto_functions.sql b/resto-database-model/01_resto_functions.sql index 80976f3a..db43815c 100644 --- a/resto-database-model/01_resto_functions.sql +++ b/resto-database-model/01_resto_functions.sql @@ -310,7 +310,7 @@ BEGIN -- Loop through the collections array and update the collection_id value FOR collection_key, collection_value IN - SELECT key, increment::INTEGER + SELECT collection_key, collection_value FROM json_each_text(counters->'collections') LOOP IF collection_key = collection_id THEN @@ -338,3 +338,62 @@ BEGIN RETURN updated_counters::JSON; END; $$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.increment_counters( + counters JSON, + increment INTEGER, + collection_id TEXT +) +RETURNS JSON AS $$ +DECLARE + -- Variable to store the total property from JSON + total INTEGER; + -- Variable to store individual keys and values from the collections array + collection_key TEXT; + collection_value INTEGER; + -- Variable to store the updated collections array + updated_collections JSONB; + -- Variable to store the updated JSON object + updated_counters JSONB; + -- Boolean to check if collection_id exists + collection_exists BOOLEAN := FALSE; +BEGIN + -- Extract the total property from the JSON object + total := (counters->>'total')::INTEGER; + + -- Increment the total by the value + total := total + increment; + + -- Initialize the updated collections as the original collections + updated_collections := counters->'collections'; + + -- Check if collection_id is NULL + IF collection_id IS NOT NULL THEN + -- Loop through the collections array and update the collection_id value + FOR collection_key, collection_value IN + SELECT key, value::INTEGER + FROM json_each_text(counters->'collections') + LOOP + IF collection_key = collection_id THEN + -- Increment the collection value by the input value + collection_value := collection_value + increment; + -- Update the collections JSONB with the new value + updated_collections := jsonb_set(updated_collections, ARRAY[collection_key], to_jsonb(collection_value)); + collection_exists := TRUE; + END IF; + END LOOP; + + -- If the collection_id does not exist, add it with the input value + IF NOT collection_exists THEN + updated_collections := jsonb_set(updated_collections, ARRAY[collection_id], to_jsonb(increment)); + END IF; + END IF; + + -- Update the JSON object with the new total and collections + updated_counters := jsonb_set(counters::jsonb, '{total}', to_jsonb(total)); + updated_counters := jsonb_set(updated_counters, '{collections}', updated_collections); + + -- Return the updated JSON object + RETURN updated_counters::JSON; +END; +$$ LANGUAGE plpgsql; diff --git a/resto-database-model/03_resto_target_model.sql b/resto-database-model/03_resto_target_model.sql index 24c1277a..3a5812a0 100644 --- a/resto-database-model/03_resto_target_model.sql +++ b/resto-database-model/03_resto_target_model.sql @@ -460,7 +460,7 @@ CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.catalog ( -- -- Feature/Catalog association -- -CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.feature_catalog ( +CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.catalog_feature ( -- Reference __DATABASE_TARGET_SCHEMA__.feature.id featureid UUID NOT NULL REFERENCES __DATABASE_TARGET_SCHEMA__.feature (id) ON DELETE CASCADE, @@ -468,6 +468,9 @@ CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.feature_catalog ( -- Reference __DATABASE_TARGET_SCHEMA__.catalog.id catalogid TEXT NOT NULL REFERENCES __DATABASE_TARGET_SCHEMA__.catalog (id) ON DELETE CASCADE, + -- Search hashtag + hashtag TEXT, + PRIMARY KEY (featureid, catalogid) ); @@ -499,6 +502,9 @@ CREATE INDEX IF NOT EXISTS idx_value_facet ON __DATABASE_TARGET_SCHEMA__.facet U CREATE INDEX IF NOT EXISTS idx_id_catalog ON __DATABASE_TARGET_SCHEMA__.catalog (public.normalize(id)); CREATE INDEX IF NOT EXISTS idx_description_catalog ON __DATABASE_TARGET_SCHEMA__.catalog USING GIN (public.normalize(description) gin_trgm_ops); +-- [TABLE __DATABASE_TARGET_SCHEMA__.hashtag] +CREATE INDEX IF NOT EXISTS idx_hashtag_catalog_feature ON __DATABASE_TARGET_SCHEMA__.catalog_feature (public.normalize(hashtag)); + -- [TABLE __DATABASE_TARGET_SCHEMA__.feature] CREATE INDEX IF NOT EXISTS idx_collection_feature ON __DATABASE_TARGET_SCHEMA__.feature USING btree (collection); CREATE INDEX IF NOT EXISTS idx_startdateidx_feature ON __DATABASE_TARGET_SCHEMA__.feature USING btree (startdate_idx); diff --git a/resto-database-model/04_resto_triggers.sql b/resto-database-model/04_resto_triggers.sql index bce73658..3c1b0fe7 100644 --- a/resto-database-model/04_resto_triggers.sql +++ b/resto-database-model/04_resto_triggers.sql @@ -59,8 +59,7 @@ BEGIN END $$ LANGUAGE plpgsql; --- --- On INSERT on __DATABASE_TARGET_SCHEMA__.feature THEN subdivide the input feature geometry and store it in geometry_part table +-- +-- [IMPORTANT] The update_geometry_part trigger is added during container startup if USE_GEOMETRY_PART is True in config -- DROP TRIGGER IF EXISTS update_geometry_part ON __DATABASE_TARGET_SCHEMA__.feature; -CREATE TRIGGER update_geometry_part AFTER INSERT ON __DATABASE_TARGET_SCHEMA__.feature FOR EACH ROW EXECUTE PROCEDURE __DATABASE_TARGET_SCHEMA__.trigger_store_geometry_part(); From 158ee7e291bb8660f63b3e4a6857459df6f0929b Mon Sep 17 00:00:00 2001 From: jjrom Date: Thu, 6 Jun 2024 16:39:20 +0200 Subject: [PATCH 08/27] removeFeature works with catalogs --- app/resto/core/RestoModel.php | 11 +-- app/resto/core/addons/STAC.php | 11 --- .../core/dbfunctions/CatalogsFunctions.php | 98 ++++++++++++++----- .../core/dbfunctions/CollectionsFunctions.php | 2 +- .../core/dbfunctions/FeaturesFunctions.php | 95 ++++++++---------- .../core/dbfunctions/FiltersFunctions.php | 25 +++-- docs/COLLECTIONS_AND_CATALOGS.md | 2 +- ...7_R140_T23XMD_20190611T193040_update.json} | 2 +- resto-database-model/01_resto_functions.sql | 4 +- 9 files changed, 134 insertions(+), 116 deletions(-) rename examples/features/{testUpdate.json => S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json} (99%) diff --git a/app/resto/core/RestoModel.php b/app/resto/core/RestoModel.php index b3d8bc99..975a99cc 100755 --- a/app/resto/core/RestoModel.php +++ b/app/resto/core/RestoModel.php @@ -357,15 +357,6 @@ abstract class RestoModel */ public $tables = array(); - /* - * Parameters to apply to database storage for products related to this model - * - * - tablePrefix : all features belonging to a collection referencing this model will be stored in a dedicated table [tablePrefix]__feature instead of feature" - */ - public $dbParams = array( - 'tablePrefix' => '' - ); - /* * Tag add-on configuration: * [IMPORTANT] strategy values: @@ -831,7 +822,7 @@ private function storeFeature($collection, $data, $params) * * (do this before getKeywords to avoid iTag process) */ - if (isset($productIdentifier) && (new FeaturesFunctions($collection->context->dbDriver))->featureExists($featureId, $collection->context->dbDriver->targetSchema . '.' . $collection->model->dbParams['tablePrefix'] . 'feature')) { + if (isset($productIdentifier) && (new FeaturesFunctions($collection->context->dbDriver))->featureExists($featureId, $collection->context->dbDriver->targetSchema . '.feature')) { RestoLogUtil::httpError(409, 'Feature ' . $featureId . ' (with productIdentifier=' . $productIdentifier . ') already in database'); } diff --git a/app/resto/core/addons/STAC.php b/app/resto/core/addons/STAC.php index b6ea5a7c..5ca46708 100644 --- a/app/resto/core/addons/STAC.php +++ b/app/resto/core/addons/STAC.php @@ -1003,17 +1003,6 @@ public function search($params, $body) $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); } diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 224c5c4e..e31eb6e7 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -161,7 +161,7 @@ public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) $catalog['id'], $catalog['title'] ?? $catalog['id'], $catalog['description'] ?? null, - $this->getLevel($catalog['id']), + isset($catalog['id']) ? count(explode('/', $catalog['id'])) : 0, // If no input counter is specified - set to 1 json_encode($counters, JSON_UNESCAPED_SLASHES), $catalog['owner'] ?? $userid, @@ -233,7 +233,7 @@ public function updateCatalog($catalog) * @param string $collectionId * @param integer $increment */ - public function updateCountsForFeature($featureId, $collectionId, $increment) + public function updateCatalogsCounts($featureId, $collectionId, $increment) { $query = join(' ', array( @@ -248,6 +248,29 @@ public function updateCountsForFeature($featureId, $collectionId, $increment) } + /** + * Get feature catalogs + * + * @param string $featureId + * @return array + */ + public function getFeatureCatalogs($featureId) + { + + $catalogs = []; + + $query = 'SELECT c.id, c.title, c.description, c.level, c.counters, c.owner, c.links, c.visibility, c.rtype, c.hashtag, to_iso8601(c.created) as created FROM ' . $this->dbDriver->targetSchema . '.catalog c, ' . $this->dbDriver->targetSchema . '.catalog_feature cf WHERE c.id = cf.catalogid AND cf.featureid=$1 ORDER BY id ASC'; + $results = $this->dbDriver->pQuery($query, array( + $featureId + )); + + while ($result = pg_fetch_assoc($results)) { + $catalogs[] = CatalogsFunctions::format($result); + } + + return $catalogs; + + } /** * Remove catalog from id - can only works if catalog has no child @@ -411,31 +434,62 @@ public function getSummaries($types, $collectionId) } /** - * Remove feature facets from database - * - * @param array $hashtags - * @param string $collectionId + * Return a diff between $oldCatalogs array and $newCatalogs array + * + * @param Array $oldCatalogs + * @param Array $newCatalogs */ - public function removeFacetsFromHashtags($hashtags, $collectionId) + public function diff($oldCatalogs, $newCatalogs) { - for ($i = count($hashtags); $i--;) { - $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.facet SET counter = GREATEST(0, counter - 1) WHERE public.normalize(id)=public.normalize($1) AND (public.normalize(collection)=public.normalize($2) OR public.normalize(collection)=\'*\')', array($hashtags[$i], $collectionId), 500, 'Cannot delete facet for ' . $collectionId); + $output = array( + 'added' => array(), + 'removed' => array() + ); + + for ($i = count($newCatalogs); $i--;) { + + // Catalogs without hashtag are discarded because they are not stored within + // catalog_feature table + if ( !isset($newCatalogs[$i]['hashtag']) ) { + continue; + } + + $isNew = true; + for ($j = count($oldCatalogs); $j--;) { + // Catalog exist => do nothing + if ($oldCatalogs[$j]['id'] === $newCatalogs[$i]['id']) { + $isNew = false; + break; + } + } + if ($isNew) { + $output['added'][] = $newCatalogs[$i]; + } } - } - /** - * Return the number of levels (i.e. number of /) from a catalog id. - * For instance 'continents/America/USA/NewYork' has a level of 4 - * - * @param string $id - * @return integer - */ - private function getLevel($id) - { - if ( !isset($id) ) { - return 0; + for ($i = count($oldCatalogs); $i--;) { + + // Catalogs without hashtag are discarded because they are not stored within + // catalog_feature table + if ( !isset($oldCatalogs[$i]['hashtag']) ) { + continue; + } + + $isRemoved = true; + for ($j = count($newCatalogs); $j--;) { + // Catalog exist => do nothing + if ($oldCatalogs[$i]['id'] === $newCatalogs[$j]['id']) { + $isRemoved = false; + break; + } + } + if ($isRemoved) { + $output['removed'][] = $oldCatalogs[$i]; + } } - return count(explode('/', $id)); + + return $output; + } } diff --git a/app/resto/core/dbfunctions/CollectionsFunctions.php b/app/resto/core/dbfunctions/CollectionsFunctions.php index 87088a23..1dee4741 100755 --- a/app/resto/core/dbfunctions/CollectionsFunctions.php +++ b/app/resto/core/dbfunctions/CollectionsFunctions.php @@ -567,7 +567,7 @@ private function storeOSDescription($collection) */ private function collectionIsEmpty($collection) { - $results = $this->dbDriver->fetch($this->dbDriver->pQuery('SELECT count(id) as count FROM ' . $this->dbDriver->targetSchema . '.' . $collection->model->dbParams['tablePrefix'] . 'feature WHERE collection=$1 LIMIT 1', array($collection->id))); + $results = $this->dbDriver->fetch($this->dbDriver->pQuery('SELECT count(id) as count FROM ' . $this->dbDriver->targetSchema . '.feature WHERE collection=$1 LIMIT 1', array($collection->id))); if ($results[0]['count'] === '0') { return true; } diff --git a/app/resto/core/dbfunctions/FeaturesFunctions.php b/app/resto/core/dbfunctions/FeaturesFunctions.php index 355a539c..fddd5ced 100755 --- a/app/resto/core/dbfunctions/FeaturesFunctions.php +++ b/app/resto/core/dbfunctions/FeaturesFunctions.php @@ -90,7 +90,7 @@ public function search($context, $user, $model, $collections, $paramsWithOperati */ $this->checkMandatoryFilters($model->searchFilters, $paramsWithOperation); - $featureTableName = $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix'] . 'feature'; + $featureTableName = $this->dbDriver->targetSchema . '.feature'; /* * Set filters @@ -238,9 +238,8 @@ public function search($context, $user, $model, $collections, $paramsWithOperati public function getFeatureDescription($context, $user, $featureId, $collection, $fields) { $model = isset($collection) ? $collection->model : new DefaultModel(); - $tablePrefix = $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix']; - $selectClause = $this->getSelectClause($tablePrefix . 'feature', $this->featureColumns, $user, array( + $selectClause = $this->getSelectClause($this->dbDriver->targetSchema . '.feature', $this->featureColumns, $user, array( 'fields' => $fields, 'useSocial' => isset($context->addons['Social']) )); @@ -249,7 +248,7 @@ public function getFeatureDescription($context, $user, $featureId, $collection, // Determine if search on id or productidentifier $filtersAndJoins['filters'][] = array( - 'value' => $tablePrefix . 'feature.id=\'' . pg_escape_string($this->dbDriver->getConnection(), (RestoUtil::isValidUUID($featureId) ? $featureId : RestoUtil::toUUID($featureId))) . '\'', + 'value' => $this->dbDriver->targetSchema . '.feature.id=\'' . pg_escape_string($this->dbDriver->getConnection(), (RestoUtil::isValidUUID($featureId) ? $featureId : RestoUtil::toUUID($featureId))) . '\'', 'isGeo' => false ); $results = $this->dbDriver->fetch($this->dbDriver->query($selectClause . ' ' . $filtersFunctions->getWhereClause($filtersAndJoins, array('sort' => false, 'addGeo' => true)))); @@ -282,7 +281,7 @@ public function featureExists($featureId, $featureTableName) */ public function storeFeature($id, $collection, $featureArray) { - $keysValues = $this->featureArrayToKeysValues( + $keysAndValues = $this->featureArrayToKeysValues( $collection, $featureArray, array( @@ -314,7 +313,7 @@ public function storeFeature($id, $collection, $featureArray) * Eventually an 'isExternal' catalog can be created during feature ingestion. * Check that user can create catalog */ - foreach (array_values($keysValues['catalogs']) as $catalog) { + foreach (array_values($keysAndValues['catalogs']) as $catalog) { if (isset($catalog['isExternal']) && $catalog['isExternal']) { if ( !$collection->user->hasRightsTo(RestoUser::CREATE_CATALOG) ) { return RestoLogUtil::httpError(403, 'Feature ingestion leads to creation of catalog ' . $catalog['id'] . ' but you don\'t have right to create catalogs'); @@ -340,17 +339,17 @@ public function storeFeature($id, $collection, $featureArray) /* * Store feature - identifier is generated with public.timestamp_to_id() */ - $result = pg_fetch_assoc($this->dbDriver->pQuery('INSERT INTO ' . $this->dbDriver->targetSchema . '.' . $collection->model->dbParams['tablePrefix'] . 'feature (' . join(',', array_keys($keysValues['keysAndValues'])) . ') VALUES (' . join(',', array_values($keysValues['params'])) . ') RETURNING id, productidentifier', array_values($keysValues['keysAndValues'])), 0); + $result = pg_fetch_assoc($this->dbDriver->pQuery('INSERT INTO ' . $this->dbDriver->targetSchema . '.feature (' . join(',', array_keys($keysAndValues['keysAndValues'])) . ') VALUES (' . join(',', array_values($keysAndValues['params'])) . ') RETURNING id, productidentifier', array_values($keysAndValues['keysAndValues'])), 0); /* * Store feature content */ - $this->storeFeatureAdditionalContent($result['id'], $collection->id, $keysValues['modelTables']); + $this->storeFeatureAdditionalContent($result['id'], $collection->id, $keysAndValues['modelTables']); /* * Store catalogs */ - (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysValues['catalogs'], $collection->user->profile['id'], $collection->id, $result['id']); + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysAndValues['catalogs'], $collection->user->profile['id'], $collection->id, $result['id']); /* * Commit everything - rollback if one of the inserts failed @@ -397,12 +396,12 @@ public function removeFeature($feature) /* * Update statistics counter for featureId - i.e. remove 1 per catalogs containing this feature */ - $catalogsUpdated = (new CatalogsFunctions($this->dbDriver))->updateCountsForFeature($feature->id, $feature->collection->id, -1); + $catalogsUpdated = (new CatalogsFunctions($this->dbDriver))->updateCatalogsCounts($feature->id, $feature->collection->id, -1); /* * Next remove */ - $result = pg_fetch_assoc($this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix'] . 'feature WHERE id=$1 RETURNING id', array($feature->id))); + $result = pg_fetch_assoc($this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.feature WHERE id=$1 RETURNING id', array($feature->id))); /* * Commit everything - rollback if one of the inserts failed @@ -439,19 +438,12 @@ public function updateFeature($feature, $collection, $newFeatureArray) // Get old feature properties $oldFeatureArray = $feature->toArray(); - // Compute new keysValues + // Compute new keysAndValues $keysAndValues = $this->featureArrayToKeysValues( $collection, $newFeatureArray, array( - /*'id' => $oldFeatureArray['id'], - 'productIdentifier' => $oldFeatureArray['properties']['productIdentifier'], - 'collection' => $oldFeatureArray['collection'], - 'visibility' => $oldFeatureArray['properties']['visibility'], - 'owner' => $oldFeatureArray['properties']['owner'],*/ 'status' => isset($newFeatureArray['properties']) && isset($newFeatureArray['properties']['status']) && is_int($newFeatureArray['properties']['status']) ? $newFeatureArray['properties']['status'] : $oldFeatureArray['properties']['status'], - /*'likes' => $oldFeatureArray['properties']['likes'], - 'comments' => $oldFeatureArray['properties']['comments'],*/ 'metadata' => array(), 'updated' => isset($newFeatureArray['properties']) && isset($newFeatureArray['properties']['updated']) ? $newFeatureArray['properties']['updated'] : 'now()', 'geometry' => $newFeatureArray['topologyAnalysis']['geometry'] ?? null, @@ -467,38 +459,35 @@ public function updateFeature($feature, $collection, $newFeatureArray) ); /* - * Eventually a catalog can be created as facet during feature ingestion. + * Eventually an 'isExternal' catalog can be created during feature ingestion. * Check that user can create catalog */ - $facetsStored = $feature->context->core['storeFacets'] && $collection->model->dbParams['storeFacets']; - if ($facetsStored) { - foreach (array_values($keysAndValues['facets']) as $facetElement) { - if (isset($facetElement['type']) && $facetElement['type'] === 'catalog') { - if ( !$collection->user->hasRightsTo(RestoUser::CREATE_CATALOG) ) { - return RestoLogUtil::httpError(403, 'Feature update leads to creation of catalog ' . $facetElement['id'] . ' but you don\'t have right to create catalogs'); - } - break; + foreach (array_values($keysAndValues['catalogs']) as $catalog) { + if (isset($catalog['isExternal']) && $catalog['isExternal']) { + if ( !$collection->user->hasRightsTo(RestoUser::CREATE_CATALOG) ) { + return RestoLogUtil::httpError(403, 'Feature update leads to creation of catalog ' . $catalog['id'] . ' but you don\'t have right to create catalogs'); } + break; } } + $catalogsFunctions = new CatalogsFunctions($this->dbDriver); + + // Get diff for catalogs + $diffCatalogs = $catalogsFunctions->diff($catalogsFunctions->getFeatureCatalogs($feature->id), $keysAndValues['catalogs']); + try { /* * Begin transaction */ $this->dbDriver->query('BEGIN'); - /* - * Table prefix depends on model - */ - $tablePrefix = $this->dbDriver->targetSchema . '.' . $collection->model->dbParams['tablePrefix']; - /* * Update description */ $toUpdate = $this->concatArrays(array_keys($keysAndValues['keysAndValues']), $keysAndValues['params'], '='); $this->dbDriver->pQuery( - 'UPDATE ' . $tablePrefix . 'feature SET ' . join(',', $toUpdate) . ' WHERE id=$' . (count($toUpdate) + 1), + 'UPDATE ' . $this->dbDriver->targetSchema . '.feature SET ' . join(',', $toUpdate) . ' WHERE id=$' . (count($toUpdate) + 1), array_merge( array_values($keysAndValues['keysAndValues']), array($feature->id) @@ -510,32 +499,32 @@ public function updateFeature($feature, $collection, $newFeatureArray) */ $this->storeFeatureAdditionalContent($feature->id, $collection->id, $keysAndValues['modelTables']); + /* + * Remove/add catalogs + */ + if ( !empty($diffCatalogs['removed']) ) { + // [IMPORTANT] First run updateCatalogCounts !! + (new CatalogsFunctions($this->dbDriver))->updateCatalogsCounts($feature->id, $feature->collection->id, -1); + $this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.catalog_feature WHERE featureid=$1', array( + $feature->id + )); + } + if ( !empty($diffCatalogs['added']) ) { + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysAndValues['catalogs'], $collection->user->profile['id'], $collection->id, $feature->id); + } + /* * Commit */ $this->dbDriver->query('COMMIT'); + } catch (Exception $e) { $this->dbDriver->query('ROLLBACK'); RestoLogUtil::httpError(500, 'Cannot update feature ' . $feature->id); } - /* - * Update facets i.e. remove old facets and add new ones - * This is non blocking i.e. if error just indicated in the result but feature is updated - */ - $facetsUpdated = true; - try { - $facetsFunctions = new FacetsFunctions($this->dbDriver); - $facetsFunctions->removeFacetsFromHashtags($oldFeatureArray['properties']['hashtags'] ?? array(), $collection->id); - if ($facetsStored) { - $facetsFunctions->storeFacets($keysAndValues['facets'], $collection->user->profile['id'], $collection->id); - } - } catch (Exception $e) { - $facetsUpdated = false; - } - return RestoLogUtil::success('Udpate feature ' . $feature->id, array( - 'facetsUpdated' => $facetsUpdated + 'id' => $feature->id )); } @@ -560,10 +549,8 @@ public function updateFeatureProperty($feature, $property, $value) } } - $model = isset($feature->collection) ? $feature->collection->model : new DefaultModel(); - try { - $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix'] . 'feature SET ' . $property . '=$1 WHERE id=$2', array( + $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.feature SET ' . $property . '=$1 WHERE id=$2', array( $value, $feature->id )); @@ -600,7 +587,7 @@ public function updateFeatureDescription($feature, $description) /* * Update description, hashtags and normalized_hashtags */ - $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.' . $model->dbParams['tablePrefix'] . 'feature SET description=$1, hashtags=$2, normalized_hashtags=public.normalize_array($2) WHERE id=$3', array( + $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.feature SET description=$1, hashtags=$2, normalized_hashtags=public.normalize_array($2) WHERE id=$3', array( $description, '{' . join(',', $hashtags) . '}', $feature->id diff --git a/app/resto/core/dbfunctions/FiltersFunctions.php b/app/resto/core/dbfunctions/FiltersFunctions.php index 36cb9f67..45515802 100755 --- a/app/resto/core/dbfunctions/FiltersFunctions.php +++ b/app/resto/core/dbfunctions/FiltersFunctions.php @@ -47,8 +47,6 @@ class FiltersFunctions private $model; - private $tablePrefix; - /** * Constructor * @@ -61,7 +59,6 @@ public function __construct($context, $user, $model) $this->context = $context; $this->user = $user; $this->model = $model; - $this->tablePrefix = $this->context->dbDriver->targetSchema . '.' . $this->model->dbParams['tablePrefix']; } /** @@ -79,7 +76,7 @@ public function prepareFilters($paramsWithOperation, $sortKey) /** * Append filter for contextual search */ - $filterCS = $this->prepareFilterQueryContextualSearch($this->tablePrefix . 'feature'); + $filterCS = $this->prepareFilterQueryContextualSearch($this->context->dbDriver->targetSchema . '.feature'); if (isset($filterCS) && $filterCS !== '') { $filters[] = $filterCS; } @@ -100,7 +97,7 @@ public function prepareFilters($paramsWithOperation, $sortKey) * Sorting special case */ if (!empty($sortKey) && ($filterName === 'resto:lt' || $filterName === 'resto:gt')) { - $sortFilters[] = $this->optimizeNotEqual($paramsWithOperation[$filterName]['operation'], $this->tablePrefix . 'feature.' . $sortKey, '\'' . pg_escape_string($this->context->dbDriver->getConnection(), $paramsWithOperation[$filterName]['value']) . '\''); + $sortFilters[] = $this->optimizeNotEqual($paramsWithOperation[$filterName]['operation'], $this->context->dbDriver->targetSchema . '.feature.' . $sortKey, '\'' . pg_escape_string($this->context->dbDriver->getConnection(), $paramsWithOperation[$filterName]['value']) . '\''); } /* @@ -113,7 +110,7 @@ public function prepareFilters($paramsWithOperation, $sortKey) */ if ($paramsWithOperation[$filterName]['value'] === 'f') { $filters[] = array( - 'value' => $this->tablePrefix . 'feature.' . $this->model->searchFilters[$filterName]['key'] . ' IN (SELECT userid FROM ' . $this->context->dbDriver->commonSchema . '.follower WHERE followerid=' . pg_escape_string($this->context->dbDriver->getConnection(), $this->user->profile['id']) . ')', + 'value' => $this->context->dbDriver->targetSchema . '.feature.' . $this->model->searchFilters[$filterName]['key'] . ' IN (SELECT userid FROM ' . $this->context->dbDriver->commonSchema . '.follower WHERE followerid=' . pg_escape_string($this->context->dbDriver->getConnection(), $this->user->profile['id']) . ')', 'isGeo' => false ); } @@ -122,7 +119,7 @@ public function prepareFilters($paramsWithOperation, $sortKey) */ elseif ($paramsWithOperation[$filterName]['value'] === 'F') { $filters[] = array( - 'value' => '(' . $this->tablePrefix . 'feature.' . $this->model->searchFilters[$filterName]['key'] . '=' . pg_escape_string($this->context->dbDriver->getConnection(), $this->user->profile['id']) . ' OR ' . $this->tablePrefix . 'feature.' . $this->model->searchFilters[$filterName]['key'] . ' IN (SELECT userid FROM ' . $this->context->dbDriver->commonSchema . '.follower WHERE followerid=' . pg_escape_string($this->context->dbDriver->getConnection(), $this->user->profile['id']) . '))', + 'value' => '(' . $this->context->dbDriver->targetSchema . '.feature.' . $this->model->searchFilters[$filterName]['key'] . '=' . pg_escape_string($this->context->dbDriver->getConnection(), $this->user->profile['id']) . ' OR ' . $this->context->dbDriver->targetSchema . '.feature.' . $this->model->searchFilters[$filterName]['key'] . ' IN (SELECT userid FROM ' . $this->context->dbDriver->commonSchema . '.follower WHERE followerid=' . pg_escape_string($this->context->dbDriver->getConnection(), $this->user->profile['id']) . '))', 'isGeo' => false ); } else { @@ -226,7 +223,7 @@ private function prepareFilterQueryContextualSearch($tableName) */ private function prepareFilterQuery($paramsWithOperation, $filterName) { - $featureTableName = $this->tablePrefix . 'feature'; + $featureTableName = $this->context->dbDriver->targetSchema . '.feature'; $exclusion = isset($paramsWithOperation[$filterName]['not']) && $paramsWithOperation[$filterName]['not'] ? true : false; /* @@ -666,12 +663,12 @@ private function getTableName($filterName) { for ($i = count($this->model->tables); $i--;) { if (in_array(strtolower($this->model->searchFilters[$filterName]['key']), $this->model->tables[$i]['columns'])) { - $this->joins[] = 'JOIN ' . $this->tablePrefix . $this->model->tables[$i]['name'] . ' ON ' . $this->tablePrefix . 'feature.id=' . $this->tablePrefix . $this->model->tables[$i]['name'] . '.id'; - return $this->tablePrefix . $this->model->tables[$i]['name']; + $this->joins[] = 'JOIN ' . $this->context->dbDriver->targetSchema . '.' . $this->model->tables[$i]['name'] . ' ON ' . $this->context->dbDriver->targetSchema . '.feature.id=' .$this->context->dbDriver->targetSchema . '.' . $this->model->tables[$i]['name'] . '.id'; + return $this->context->dbDriver->targetSchema . '.' . $this->model->tables[$i]['name']; } } - return $this->tablePrefix . 'feature'; + return $this->context->dbDriver->targetSchema . '.feature'; } /** @@ -684,11 +681,11 @@ private function getTableName($filterName) private function getGeometryTableName() { if ($this->context->dbDriver->useGeometryPart) { - $this->joins[] = 'JOIN ' . $this->tablePrefix . 'geometry_part ON ' . $this->tablePrefix . 'feature.id=' . $this->tablePrefix . 'geometry_part.id'; - return $this->tablePrefix . 'geometry_part'; + $this->joins[] = 'JOIN ' . $this->context->dbDriver->targetSchema . '.geometry_part ON ' . $this->context->dbDriver->targetSchema . '.feature.id=' . $this->context->dbDriver->targetSchema . '.geometry_part.id'; + return $this->context->dbDriver->targetSchema . '.geometry_part'; } - return $this->tablePrefix . 'feature'; + return $this->context->dbDriver->targetSchema . '.feature'; } /** diff --git a/docs/COLLECTIONS_AND_CATALOGS.md b/docs/COLLECTIONS_AND_CATALOGS.md index 64626c95..64d47bc8 100644 --- a/docs/COLLECTIONS_AND_CATALOGS.md +++ b/docs/COLLECTIONS_AND_CATALOGS.md @@ -32,7 +32,7 @@ To ingest a feature using the default **ADMIN_USER_NAME** and **ADMIN_USER_PASSW curl -X POST -d@examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040.json "http://admin:admin@localhost:5252/collections/S2/items" # Update a dummy feature inside the S2 collection - curl -X PUT -d@examples/features/testUpdate.json "http://admin:admin@localhost:5252/collections/S2/items/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040" + curl -X PUT -d@examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json "http://admin:admin@localhost:5252/collections/S2/items/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040" Then get the feature : diff --git a/examples/features/testUpdate.json b/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json similarity index 99% rename from examples/features/testUpdate.json rename to examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json index c2f7c957..b0ab9061 100644 --- a/examples/features/testUpdate.json +++ b/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json @@ -56,7 +56,7 @@ "productType": "REFLECTANCE", "processingLevel": "LEVEL1C", "platform": "S2A", - "instrument": "MSI", + "instrument": "MSI_CHANGED", "tileId": "23XMD", "dataTakeId": "GS2A_20190611T160901_020729_N02.07", "eo:cloud_cover": 54.76, diff --git a/resto-database-model/01_resto_functions.sql b/resto-database-model/01_resto_functions.sql index db43815c..98682677 100644 --- a/resto-database-model/01_resto_functions.sql +++ b/resto-database-model/01_resto_functions.sql @@ -362,7 +362,7 @@ BEGIN total := (counters->>'total')::INTEGER; -- Increment the total by the value - total := total + increment; + total := GREATEST(0, total + increment); -- Initialize the updated collections as the original collections updated_collections := counters->'collections'; @@ -376,7 +376,7 @@ BEGIN LOOP IF collection_key = collection_id THEN -- Increment the collection value by the input value - collection_value := collection_value + increment; + collection_value := GREATEST(0, collection_value + increment); -- Update the collections JSONB with the new value updated_collections := jsonb_set(updated_collections, ARRAY[collection_key], to_jsonb(collection_value)); collection_exists := TRUE; From 0428bcc9bfbcaca11deb7fcbc552de9ec0413347 Mon Sep 17 00:00:00 2001 From: jjrom Date: Thu, 6 Jun 2024 17:26:46 +0200 Subject: [PATCH 09/27] Temporary deactivate updateFeatureDescription function since it is not yet validated --- .../core/dbfunctions/CatalogsFunctions.php | 32 +++++++++++- .../core/dbfunctions/FeaturesFunctions.php | 52 +++++++++---------- 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index e31eb6e7..8ca60908 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -226,6 +226,34 @@ public function updateCatalog($catalog) } + /** + * Increment input catalogs count + * + * @param array $catalogs + * @param string $collectionId + * @param integer $increment + */ + public function updateCatalogsCounters($catalogs, $collectionId, $increment) + { + + $catalogIds = []; + for ($i = count($catalogs); $i--;) { + $catalogIds[] = $catalogs[$i]['id']; + } + + $query = join(' ', array( + 'UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET counters=public.increment_counters(counters,' . $increment . ',' . (isset($collectionId) ? '\'' . $collectionId . '\'': 'NULL') . ')', + 'FROM ' . $this->dbDriver->targetSchema . '.catalog', + 'WHERE id IN (\'' . join('\',\'', $catalogIds) . '\') RETURNING id' + )); + + $results = $this->dbDriver->fetch($this->dbDriver->query($query)); + + return count($results); + + } + + /** * Increment all catalogs relied to feature * @@ -233,7 +261,7 @@ public function updateCatalog($catalog) * @param string $collectionId * @param integer $increment */ - public function updateCatalogsCounts($featureId, $collectionId, $increment) + public function updateFeatureCatalogsCounters($featureId, $collectionId, $increment) { $query = join(' ', array( @@ -351,7 +379,7 @@ public function getSummaries($types, $collectionId) $pivots = array(); $catalogs = $this->getCatalogs(array( - 'where' => !empty($types) ? 'rtype IN(\'' . join('\',\'', $types) . '\')' : 'rtype NOT IN (\'' . join('\',\'', CatalogsFunctions::TOPONYM_TYPES) . '\')' + 'where' => !empty($types) ? 'rtype IN (\'' . join('\',\'', $types) . '\')' : 'rtype NOT IN (\'' . join('\',\'', CatalogsFunctions::TOPONYM_TYPES) . '\')' )); $counter = 0; diff --git a/app/resto/core/dbfunctions/FeaturesFunctions.php b/app/resto/core/dbfunctions/FeaturesFunctions.php index fddd5ced..64e17c8a 100755 --- a/app/resto/core/dbfunctions/FeaturesFunctions.php +++ b/app/resto/core/dbfunctions/FeaturesFunctions.php @@ -396,7 +396,7 @@ public function removeFeature($feature) /* * Update statistics counter for featureId - i.e. remove 1 per catalogs containing this feature */ - $catalogsUpdated = (new CatalogsFunctions($this->dbDriver))->updateCatalogsCounts($feature->id, $feature->collection->id, -1); + $catalogsUpdated = (new CatalogsFunctions($this->dbDriver))->updateFeatureCatalogsCounters($feature->id, $feature->collection->id, -1); /* * Next remove @@ -504,13 +504,13 @@ public function updateFeature($feature, $collection, $newFeatureArray) */ if ( !empty($diffCatalogs['removed']) ) { // [IMPORTANT] First run updateCatalogCounts !! - (new CatalogsFunctions($this->dbDriver))->updateCatalogsCounts($feature->id, $feature->collection->id, -1); + (new CatalogsFunctions($this->dbDriver))->updateCatalogsCounters($diffCatalogs['removed'], $feature->collection->id, -1); $this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.catalog_feature WHERE featureid=$1', array( $feature->id )); } if ( !empty($diffCatalogs['added']) ) { - (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysAndValues['catalogs'], $collection->user->profile['id'], $collection->id, $feature->id); + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($diffCatalogs['added'], $collection->user->profile['id'], $collection->id, $feature->id); } /* @@ -571,19 +571,19 @@ public function updateFeatureProperty($feature, $property, $value) public function updateFeatureDescription($feature, $description) { - return 'TODO'; - - // Get hashtags to remove from feature before update - $hashtagsToRemove = $this->extractHashtagsFromText($feature->toArray()['properties']['description'], true); - $hashtagsToAdd = $this->extractHashtagsFromText($description, true); - $hashtags = array_merge(array_diff($feature->toArray()['properties']['hashtags'], $hashtagsToRemove), $hashtagsToAdd); + return RestoLogUtil::httpError(400, 'TODO - update feature description not yet implemented'); + + $cataloger = new Cataloger($feature->collection->context, $feature->collection->user); + $catalogsFunctions = new CatalogsFunctions($this->dbDriver); + + // Get diff for catalogs + $diffCatalogs = $catalogsFunctions->diff($cataloger->catalogsFromText($feature->toArray()['properties']['description']), $cataloger->catalogsFromText($description)); - $model = isset($feature->collection) ? $feature->collection->model : new DefaultModel(); - /* * Transaction */ try { + /* * Update description, hashtags and normalized_hashtags */ @@ -592,27 +592,27 @@ public function updateFeatureDescription($feature, $description) '{' . join(',', $hashtags) . '}', $feature->id )); - } catch (Exception $e) { - RestoLogUtil::httpError(500, 'Cannot update feature ' . $feature->id); - } - /* - * Update facets i.e. remove old facets and add new ones - * This is non blocking i.e. if error just indicated in the result but feature is updated - */ - $facetsUpdated = true; - try { - $facetsFunctions = new FacetsFunctions($this->dbDriver); - $facetsFunctions->removeFacetsFromHashtags($hashtagsToRemove, $feature->collection->id); - if ($feature->context->core['storeFacets'] && $model->dbParams['storeFacets']) { - $facetsFunctions->storeFacets($hashtagsToAdd, $feature->user->profile['id'], $feature->collection->id); + /* + * Remove/add catalogs + */ + if ( !empty($diffCatalogs['removed']) ) { + // [IMPORTANT] First run updateCatalogsCounters !! + (new CatalogsFunctions($this->dbDriver))->updateCatalogsCounters($diffCatalogs['removed'], $feature->collection->id, -1); + $this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.catalog_feature WHERE featureid=$1', array( + $feature->id + )); + } + if ( !empty($diffCatalogs['added']) ) { + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($diffCatalogs['added'], $feature->collection->user->profile['id'], $feature->collection->id, $feature->id); } + } catch (Exception $e) { - $facetsUpdated = false; + RestoLogUtil::httpError(500, 'Cannot update feature ' . $feature->id); } return RestoLogUtil::success('Property description updated for feature ' . $feature->id, array( - 'facetsUpdated' => $facetsUpdated + 'id' => $feature->id )); } From f5a6b6a573835e737ec6e0883e5434baf80c3518 Mon Sep 17 00:00:00 2001 From: jjrom Date: Fri, 7 Jun 2024 08:44:12 +0200 Subject: [PATCH 10/27] First level GET / and /catalogs works --- app/resto/core/RestoRouter.php | 1 + app/resto/core/addons/STAC.php | 282 ++++++++++-------- app/resto/core/addons/STACCatalog.php | 24 +- app/resto/core/api/ServicesAPI.php | 142 ++++----- .../core/dbfunctions/CatalogsFunctions.php | 30 +- app/resto/core/utils/STACUtil.php | 255 ---------------- .../03_resto_target_model.sql | 1 + 7 files changed, 260 insertions(+), 475 deletions(-) diff --git a/app/resto/core/RestoRouter.php b/app/resto/core/RestoRouter.php index 8fe8ce29..3bf81229 100755 --- a/app/resto/core/RestoRouter.php +++ b/app/resto/core/RestoRouter.php @@ -113,6 +113,7 @@ class RestoRouter // STAC array('GET', RestoRouter::ROUTE_TO_ASSETS . '/{urlInBase64}', false, 'STAC::getAsset'), // Get an asset using HTTP 301 permanent redirect + array('GET', RestoRouter::ROUTE_TO_CATALOGS, false, 'STAC::getCatalogs'), array('GET', RestoRouter::ROUTE_TO_CATALOGS . '/*', false, 'STAC::getCatalogs'), // Get catalogs array('GET', RestoRouter::ROUTE_TO_STAC_CHILDREN, false, 'STAC::getChildren'), // STAC API - Children array('GET', RestoRouter::ROUTE_TO_STAC_QUERYABLES, false, 'STAC::getQueryables'), // STAC/OAFeature API - Queryables diff --git a/app/resto/core/addons/STAC.php b/app/resto/core/addons/STAC.php index 5ca46708..7fe24bd8 100644 --- a/app/resto/core/addons/STAC.php +++ b/app/resto/core/addons/STAC.php @@ -229,16 +229,6 @@ class STAC extends RestoAddOn 'http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-operators' ); - /* - * Catalog title - */ - public $title; - - /* - * Catalog description - */ - public $description = 'Available catalogs'; - /* * Links */ @@ -249,11 +239,6 @@ class STAC extends RestoAddOn */ public $featureCollection = null; - /* - * STAC Util - */ - private $stacUtil = null; - /* * Url segments */ @@ -268,12 +253,51 @@ class STAC extends RestoAddOn 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 a STAC catalog + * + * @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 array( + 'stac_version' => STAC::STAC_VERSION, + 'id' => 'catalogs', + 'type' => 'Catalog', + 'title' => 'Catalogs', + 'description' => 'List of available catalogs', + 'links' => array_merge( + $this->getBaseLinks(), + $this->getRootCatalogLinks($this->options['minMatch']) + ) + ); + } + + // This is /catalogs/* + return $this->processPath($params['segments'], $params); + } + /** * * Return an asset href within an HTTP 301 Redirect message @@ -337,43 +361,6 @@ public function getAsset($params) 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) @@ -1047,35 +1034,49 @@ 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 + * Return STAC catalog from path * + * @param array $segments * @param array $params * @return This object */ - private function load($params = array()) + private function processPath($segments, $params = array()) { - $nbOfSegments = count($this->segments); - - // Root - if ($nbOfSegments === 0) { - $this->links = $this->stacUtil->getRootCatalogLinks($this->options['minMatch']); + + /* + * Addons special case - not handle within resto.catalog table + */ + $resultFromAddons = $this->processAddons($segments, $params); + if ( isset($resultFromAddons) ) { + return $resultFromAddons; } + + // The path is the catalog identifier + $catalogId = join('/', $segments); + + + return; + + } + + /** + * + */ + private function processAddons($segments, $params) + { + + $nbOfSegments = count($segments); + + if ($this->segments[0] === 'views' && isset($this->context->addons['View'])) { - // 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( + return $view->getViews(array( 'format' => 'stac' )); - $this->title = $viewCatalog['title']; - $this->description = $viewCatalog['description']; - $this->links = $viewCatalog['links']; } // Individual view elseif ($nbOfSegments === 2) { @@ -1084,14 +1085,13 @@ private function load($params = array()) 'format' => 'stac' ))); } - // Individual views else { return RestoLogUtil::httpError(404); } } // SOSA special case - elseif ($this->segments[0] === 'concepts' && isset($this->context->addons['SOSA'])) { + else if ($this->segments[0] === 'concepts' && isset($this->context->addons['SOSA'])) { $skos = new SKOS($this->context, $this->user); // Root @@ -1104,61 +1104,13 @@ private function load($params = 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 or catalogs - 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; + return null; } - /** * Set links array for a given theme * @@ -1239,13 +1191,14 @@ private function setClassificationsLinks($root) } /** - * Initialize child catalogs from facet + * Build catalog * * @param array $params * @return array */ - private function setCatalogsLinksFromFacet($params) + private function buildCatalog($params) { + $nbOfSegments = count($this->segments); $leafValue = $this->segments[$nbOfSegments - 1]; @@ -1253,6 +1206,7 @@ private function setCatalogsLinksFromFacet($params) * Special case for '_' leafValue => compute FeatureCollection of parents */ if ($leafValue === '_') { + // This is not possible if ($nbOfSegments < 2) { return RestoLogUtil::httpError(404); @@ -1496,4 +1450,86 @@ private function jsonQueryToKVP($jsonQuery) } + /** + * Return self/root/parent links + * + * @param array segments + */ + private function getBaseLinks($segments = array()) + { + + array_unshift($segments, 'catalogs'); + + $links = array( + array( + 'rel' => 'self', + 'type' => RestoUtil::$contentTypes['json'], + 'href' => $this->context->core['baseUrl'] . '/' . join('/', array_map('rawurlencode', $segments)) + ), + array( + 'rel' => 'root', + 'type' => RestoUtil::$contentTypes['json'], + 'href' => $this->context->core['baseUrl'] + ) + ); + + array_pop($segments); + $links[] = array( + 'rel' => 'parent', + 'type' => RestoUtil::$contentTypes['json'], + 'href' => $this->context->core['baseUrl'] . (count($segments) > 0 ? '/' . join('/', array_map('rawurlencode', $segments)) : '') + ); + + return $links; + + } + + /** + * Get root links + * + * @return array + */ + private function getRootCatalogLinks() + { + $links = array(); + + /* + * Exposed views as STAC catalogs + * Only displayed if at least one theme exists + */ + if (isset($this->context->addons['View'])) { + $stacLink = (new View($this->context, $this->user))->getSTACRootLink(); + if (isset($stacLink) && $stacLink['matched'] > 0) { + $links[] = $stacLink; + } + } + + // Get first level catalog + $catalogs = (new CatalogsFunctions($this->context->dbDriver))->getCatalogs(array( + 'level' => 1 + )); + + for ($i = 0, $ii = count($catalogs); $i < $ii; $i++) { + + // Returns only catalogs with count >= minMath + if ($catalogs[$i]['counters']['total'] >= $this->options['minMatch']) { + $link = array( + 'rel' => 'child', + 'title' => $catalogs[$i]['title'], + 'description' => $catalogs[$i]['description'] ?? '', + 'type' => RestoUtil::$contentTypes['json'], + 'href' => $this->context->core['baseUrl'] . '/catalogs/' . rawurlencode($catalogs[$i]['id']), + 'matched' => $catalogs[$i]['counters']['total'] + ); + if ($catalogs[$i]['id'] === 'collections') { + $link['roles'] = array('collections'); + } + $links[] = $link; + } + + } + + return $links; + } + } diff --git a/app/resto/core/addons/STACCatalog.php b/app/resto/core/addons/STACCatalog.php index 19828ce8..acca4432 100644 --- a/app/resto/core/addons/STACCatalog.php +++ b/app/resto/core/addons/STACCatalog.php @@ -27,6 +27,8 @@ class STACCatalog extends RestoAddOn */ private $prefix = 'catalog:'; + private catalogsFunctions; + /** * Constructor * @@ -36,6 +38,7 @@ class STACCatalog extends RestoAddOn public function __construct($context, $user) { parent::__construct($context, $user); + $this->catalogsFunctions = new CatalogsFunctions($this->context->dbDriver); } /** @@ -330,26 +333,13 @@ public function removeCatalog($params) * Check if catalog exists * * @param string $catalogId - * @return boolean * @throws Exception */ - private function catalogExists($catalogId, $parentId, $collectionId) + private function catalogExists($catalogId) { - - $params = array(); - if ( isset($catalogId) ) { - $params['id'] = $catalogId; - } - if ( isset($parentId) ) { - $params['pid'] = $parentId; - } - if ( isset($collectionId) ) { - $params['collection'] = $collectionId; - } - $facets = (new FacetsFunctions($this->context->dbDriver))->getFacets($params); - - return !empty($facets); - + return !empty($this->catalogsFunctions->getCatalogs(array( + 'id' => $catalogId + ))); } /** diff --git a/app/resto/core/api/ServicesAPI.php b/app/resto/core/api/ServicesAPI.php index 4d974d5f..42093d88 100755 --- a/app/resto/core/api/ServicesAPI.php +++ b/app/resto/core/api/ServicesAPI.php @@ -181,83 +181,91 @@ public function conformance() public function hello() { - $minMatch = isset($this->context->addons['STAC']['options']['minMatch']) && is_int($this->context->addons['STAC']['options']['minMatch']) ? $this->context->addons['STAC']['options']['minMatch'] : 0; - return array( 'stac_version' => STAC::STAC_VERSION, - 'id' => 'catalogs', + 'id' => 'root', 'type' => 'Catalog', 'title' => $this->title, 'description' => $this->description, 'capabilities' => array_merge(array('resto-core'), array_map('strtolower', array_keys($this->context->addons))), 'resto:version' => RestoConstants::VERSION, 'ssys:targets' => array($this->context->core['planet']), - 'links' => array_merge( + 'links' => array( array( - array( - 'rel' => 'self', - 'type' => RestoUtil::$contentTypes['json'], - 'title' => $this->title, - 'href' => $this->context->core['baseUrl'] - ), - array( - 'rel' => 'service-desc', - 'type' => RestoUtil::$contentTypes['openapi+json'], - 'title' => 'OpenAPI 3.0 definition endpoint', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_API, - ), - array( - 'rel' => 'service-doc', - 'type' => RestoUtil::$contentTypes['html'], - 'title' => 'OpenAPI 3.0 definition endpoint documentation', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_API . '.html' - ), - array( - 'rel' => 'conformance', - 'type' => RestoUtil::$contentTypes['json'], - 'title' => 'Conformance declaration', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CONFORMANCE - ), - array( - 'rel' => 'children', - 'type' => RestoUtil::$contentTypes['json'], - 'title' => 'Children', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_CHILDREN - ), - array( - 'rel' => 'http://www.opengis.net/def/rel/ogc/1.0/queryables', - 'type' => RestoUtil::$contentTypes['jsonschema'], - 'title' => 'Queryables', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_QUERYABLES - ), - array( - 'rel' => 'data', - 'type' => RestoUtil::$contentTypes['json'], - 'title' => 'Collections', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_COLLECTIONS - ), - array( - 'rel' => 'root', - 'type' => RestoUtil::$contentTypes['json'], - 'title' => getenv('API_INFO_TITLE'), - 'href' => $this->context->core['baseUrl'] - ), - array( - 'rel' => 'search', - 'type' => RestoUtil::$contentTypes['geojson'], - 'title' => 'STAC search endpoint (GET)', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_SEARCH, - 'method' => 'GET' - ), - array( - 'rel' => 'search', - 'type' => RestoUtil::$contentTypes['geojson'], - 'title' => 'STAC search endpoint (POST)', - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_SEARCH, - 'method' => 'POST' - ) + 'rel' => 'self', + 'type' => RestoUtil::$contentTypes['json'], + 'title' => $this->title, + 'href' => $this->context->core['baseUrl'] ), - (new STACUtil($this->context, $this->user))->getRootCatalogLinks($minMatch) + array( + 'rel' => 'service-desc', + 'type' => RestoUtil::$contentTypes['openapi+json'], + 'title' => 'OpenAPI 3.0 definition endpoint', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_API, + ), + array( + 'rel' => 'service-doc', + 'type' => RestoUtil::$contentTypes['html'], + 'title' => 'OpenAPI 3.0 definition endpoint documentation', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_API . '.html' + ), + array( + 'rel' => 'conformance', + 'type' => RestoUtil::$contentTypes['json'], + 'title' => 'Conformance declaration', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CONFORMANCE + ), + array( + 'rel' => 'children', + 'type' => RestoUtil::$contentTypes['json'], + 'title' => 'Children', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_CHILDREN + ), + array( + 'rel' => 'http://www.opengis.net/def/rel/ogc/1.0/queryables', + 'type' => RestoUtil::$contentTypes['jsonschema'], + 'title' => 'Queryables', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_QUERYABLES + ), + array( + 'rel' => 'data', + 'type' => RestoUtil::$contentTypes['json'], + 'title' => 'Collections', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_COLLECTIONS + ), + array( + 'rel' => 'child', + 'title' => 'Collections', + 'type' => RestoUtil::$contentTypes['json'], + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_COLLECTIONS, + 'roles' => array('collections') + ), + array( + 'rel' => 'catalogs', + 'type' => RestoUtil::$contentTypes['json'], + 'title' => 'Catalogs', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS + ), + array( + 'rel' => 'root', + 'type' => RestoUtil::$contentTypes['json'], + 'title' => getenv('API_INFO_TITLE'), + 'href' => $this->context->core['baseUrl'] + ), + array( + 'rel' => 'search', + 'type' => RestoUtil::$contentTypes['geojson'], + 'title' => 'STAC search endpoint (GET)', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_SEARCH, + 'method' => 'GET' + ), + array( + 'rel' => 'search', + 'type' => RestoUtil::$contentTypes['geojson'], + 'title' => 'STAC search endpoint (POST)', + 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_STAC_SEARCH, + 'method' => 'POST' + ) ), 'conformsTo' => $this->conformsTo() ); diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 8ca60908..a95a2ddb 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -93,25 +93,29 @@ public function getCatalogs($params, $withChilds = false) $values = array(); $params = isset($params) ? $params : array(); + // Direct where clause + if ( isset($params['where'])) { + $where[] = $params['where']; + } + if ( isset($params['id']) ) { - if ($withChilds) { - $values[] = $params['id'] . '%'; - $where[] = 'public.normalize(id) LIKE public.normalize(' . count($values) . ')'; - - } - else { - $values[] = $params['id']; - $where[] = 'public.normalize(id) = public.normalize(' . count($values) . ')'; + $values[] = $params['id']; + $_where = 'public.normalize(id) = public.normalize($' . count($values) . ')'; + if ( $withChilds ) { + $values[] = $params['id'] . '/%'; + $_where = '(' . $_where . ' OR public.normalize(id) LIKE public.normalize($' . count($values) . '))'; } + $where[] = $_where; } + if ( isset($params['description']) ) { $values[] = '%' . $params['description'] . '%'; - $where[] = 'public.normalize(description) LIKE public.normalize(' . count($values) . ')'; + $where[] = 'public.normalize(description) LIKE public.normalize($' . count($values) . ')'; } - - // Direct where clause - if ( isset($params['where'])) { - $where[] = $params['where']; + + if ( isset($params['level']) ) { + $values[] = $params['level']; + $where[] = 'level=$' . count($values); } $results = $this->dbDriver->pQuery('SELECT id, title, description, level, counters, owner, links, visibility, rtype, hashtag, to_iso8601(created) as created FROM ' . $this->dbDriver->targetSchema . '.catalog' . ( empty($where) ? '' : ' WHERE ' . join(' AND ', $where) . ' ORDER BY id ASC'), $values); diff --git a/app/resto/core/utils/STACUtil.php b/app/resto/core/utils/STACUtil.php index 9c7da0d3..9097383b 100755 --- a/app/resto/core/utils/STACUtil.php +++ b/app/resto/core/utils/STACUtil.php @@ -31,18 +31,6 @@ class STACUtil */ private $user; - /* - * Classifications - */ - public $classifications = array( - 'geographical' => array( - 'continent', 'location', 'ocean', 'sea', 'river', 'bay', 'channel', 'fjord', 'gulf', 'inlet', 'lagoon', 'sound', 'strait' - ), - 'temporal' => array( - 'season','year', 'month', 'day' - ) - ); - /** * Constructor * @@ -55,249 +43,6 @@ public function __construct($context, $user) $this->user =$user; } - /** - * Get root links - * - * @param integer $minMatch - * - * @return array - */ - public function getRootCatalogLinks($minMatch) - { - $links = array(); - - /* - * Themes are built from theme:xxxx collection keywords - * Only displayed if at least one theme exists - */ - if (!empty($this->getThemesRootLinks())) { - $links[] = array( - 'rel' => 'child', - 'title' => 'Themes', - 'type' => RestoUtil::$contentTypes['json'], - 'href' => $this->context->core['baseUrl'] . '/catalogs/themes' - ); - } - - /* - * [STAC] Duplicate rel="data" - */ - $collections = ($this->context->keeper->getRestoCollections($this->user)->load())->toArray(); - if (count($collections) > 0) { - $links[] = array( - 'rel' => 'child', - 'title' => 'Collections', - 'type' => RestoUtil::$contentTypes['json'], - 'matched' => count($collections['collections']), - 'href' => $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_COLLECTIONS, - 'roles' => array('collections') - ); - } - - /* - * SOSA concepts - * Only displayed if at least one concept exists - */ - if (isset($this->context->addons['SOSA'])) { - $skos = new SKOS($this->context, $this->user); - $concepts = $skos->getConcepts($this->context->query); - if (count($concepts['links']) > 2) { - $links[] = array( - 'rel' => 'child', - 'title' => 'Concepts', - 'type' => RestoUtil::$contentTypes['json'], - 'href' => $this->context->core['baseUrl'] . '/catalogs/concepts' - ); - } - } - /* - * Exposed views as STAC catalogs - * Only displayed if at least one theme exists - */ - if (isset($this->context->addons['View'])) { - $stacLink = (new View($this->context, $this->user))->getSTACRootLink(); - if (isset($stacLink) && $stacLink['matched'] > 0) { - $links[] = $stacLink; - } - } - - $facets = $this->getFacetsCount($minMatch); - - foreach (array('catalogs', 'facets') as $key) { - - $childKeys = $facets[$key]; - if (isset($childKeys)) { - - // Remove the "catalogs" and "facets" childs level and directly - // merge there respective childs to the root endpoint - if ( $this->context->core['mergeRootCatalogLinks']) { - if ( !is_array($childKeys) && $key === 'catalogs' ) { - $childKeys = $this->getCatalogChilds(); - } - foreach (array_keys($childKeys) as $childKey) { - $link = array( - 'rel' => 'child', - 'title' => isset($childKeys[$childKey]['title']) ? $childKeys[$childKey]['title'] : ucfirst($childKey), - 'type' => RestoUtil::$contentTypes['json'], - 'href' => $this->context->core['baseUrl'] . '/catalogs/' . rawurlencode($key) . '/' . rawurlencode($childKey) - ); - if ( isset($childKeys[$childKey]['count']) ) { - $link['count'] = $childKeys[$childKey]['count']; - } - $links[] = $link; - } - - } - else { - $links[] = array( - 'rel' => 'child', - 'title' => ucfirst($key), - 'type' => RestoUtil::$contentTypes['json'], - 'href' => $this->context->core['baseUrl'] . '/catalogs/' . rawurlencode($key) - ); - } - } - } - - // Hashtags - if (isset($facets['hashtags'])) { - $links[] = array( - 'rel' => 'child', - 'title' => ucfirst('hashtags'), - 'type' => RestoUtil::$contentTypes['json'], - 'href' => $this->context->core['baseUrl'] . '/catalogs/' . rawurlencode('hashtags') - ); - } - - return $links; - } - - /** - * Return Themes links - * - * @return array - */ - public function getThemesRootLinks() - { - $links = array(); - - // Load collections - $collections = $this->context->keeper->getRestoCollections($this->user)->load(); - - // Get themes - $themes = 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' && !in_array($splitted[1], $themes)) { - $themes[] = $splitted[1]; - $links[] = array( - 'rel' => 'child', - 'title' => $splitted[1], - 'type' => RestoUtil::$contentTypes['json'], - 'href' => $this->context->core['baseUrl'] . '/catalogs/themes/' . rawurlencode($splitted[1]) - ); - } - } - } - } - - return $links; - } - - /** - * Return count per facet type - * - * @param integer $minMatch - * @return array - */ - public function getFacetsCount($minMatch) - { - $facets = array( - 'count' => 0, - 'catalogs' => array(), - 'hashtags' => array(), - 'facets' => array() - ); - - foreach ($this->classifications as $key => $value) { - $facets['facets'][$key] = array(); - } - - try { - $results = $this->context->dbDriver->query('SELECT split_part(type, \':\', 1) as type, sum(counter) as matched FROM ' . $this->context->dbDriver->targetSchema . '.facet WHERE public.normalize(pid)=\'root\' GROUP BY split_part(type, \':\', 1) ORDER BY type ASC'); - - if (!$results) { - throw new Exception(); - } - - while ($result = pg_fetch_assoc($results)) { - $matched = (integer) $result['matched']; - - if ($result['type'] === 'collection') { - $facets['count'] = $matched; - } else { - if ($matched >= $minMatch) { - // Catalog - if ($result['type'] === 'catalog' || $result['type'] === 'hashtag') { - $facets[$result['type'] . 's'] = $matched; - } else { - $addToFacets = true; - foreach ($this->classifications as $key => $value) { - for ($i = count($value); $i--;) { - if ($result['type'] === $value[$i]) { - $facets['facets'][$key][$result['type']] = $matched; - $addToFacets = false; - break; - } - } - } - if ($addToFacets) { - $result['type'] === 'landcover' ? $facets['facets']['landcover'] = $matched : $facets['facets'][$result['type']] = $matched; - } - } - } - } - } - } catch (Exception $e) { - return $facets; - } - - return $facets; - } - - /** - * Return catalog childs key with counts - * - * @return array - */ - private function getCatalogChilds() - { - $childs = array(); - - try { - - $results = $this->context->dbDriver->query('SELECT id, value, pid, isleaf, sum(counter) as matched FROM resto.facet WHERE type=\'catalog\' AND public.normalize(pid)=\'root\' GROUP BY id, value, pid, isleaf ORDER BY value ASC'); - - if (!$results) { - throw new Exception(); - } - - while ($result = pg_fetch_assoc($results)) { - $matched = (integer) $result['matched']; - $childs[$result['id']] = array( - 'count' => $matched, - 'title' => $result['value'] - ); - } - } catch (Exception $e) { - // - } - - return $childs; - } } \ No newline at end of file diff --git a/resto-database-model/03_resto_target_model.sql b/resto-database-model/03_resto_target_model.sql index 3a5812a0..ff7d44a9 100644 --- a/resto-database-model/03_resto_target_model.sql +++ b/resto-database-model/03_resto_target_model.sql @@ -501,6 +501,7 @@ CREATE INDEX IF NOT EXISTS idx_value_facet ON __DATABASE_TARGET_SCHEMA__.facet U -- [TABLE __DATABASE_TARGET_SCHEMA__.catalog] CREATE INDEX IF NOT EXISTS idx_id_catalog ON __DATABASE_TARGET_SCHEMA__.catalog (public.normalize(id)); CREATE INDEX IF NOT EXISTS idx_description_catalog ON __DATABASE_TARGET_SCHEMA__.catalog USING GIN (public.normalize(description) gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_level_catalog ON __DATABASE_TARGET_SCHEMA__.catalog USING btree (level); -- [TABLE __DATABASE_TARGET_SCHEMA__.hashtag] CREATE INDEX IF NOT EXISTS idx_hashtag_catalog_feature ON __DATABASE_TARGET_SCHEMA__.catalog_feature (public.normalize(hashtag)); From 0f25c7e0271994179d751c44699419c4ee404309 Mon Sep 17 00:00:00 2001 From: jjrom Date: Fri, 7 Jun 2024 10:57:59 +0200 Subject: [PATCH 11/27] STAC catalogs navigation works :) --- app/resto/core/addons/STAC.php | 464 +++++------------- .../core/dbfunctions/CatalogsFunctions.php | 55 ++- .../03_resto_target_model.sql | 3 + 3 files changed, 164 insertions(+), 358 deletions(-) diff --git a/app/resto/core/addons/STAC.php b/app/resto/core/addons/STAC.php index 7fe24bd8..0c4c0e5d 100644 --- a/app/resto/core/addons/STAC.php +++ b/app/resto/core/addons/STAC.php @@ -230,19 +230,9 @@ class STAC extends RestoAddOn ); /* - * Links - */ - public $links = array(); - - /* - * FeatureCollection - */ - public $featureCollection = null; - - /* - * Url segments + * Reference to catalogsFunctions */ - private $segments = array(); + private $catalogsFunctions; /** * Constructor @@ -254,6 +244,7 @@ public function __construct($context, $user) { parent::__construct($context, $user); $this->options['minMatch'] = isset($this->options['minMatch']) && is_int($this->options['minMatch']) ? $this->options['minMatch'] : 0; + $this->catalogsFunctions = new CatalogsFunctions($this->context->dbDriver); } /** @@ -391,7 +382,7 @@ public function getChildren($params) // Initialize router to process each children individually $router = new RestoRouter($this->context, $this->user); - $links = $this->stacUtil->getRootCatalogLinks($this->options['minMatch']); + $links = $this->getRootCatalogLinks($this->options['minMatch']); for ($i = 0, $ii = count($links); $i < $ii; $i++) { if ($links[$i]['rel'] == 'child') { try { @@ -993,53 +984,12 @@ public function search($params, $body) 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); - } - /** * Return STAC catalog from path * * @param array $segments * @param array $params - * @return This object + * @return array */ private function processPath($segments, $params = array()) { @@ -1052,23 +1002,68 @@ private function processPath($segments, $params = array()) return $resultFromAddons; } - // The path is the catalog identifier - $catalogId = join('/', $segments); + /* + * Special case for '_' => compute FeatureCollection instead of catalogs + */ + if ($segments[count($segments) - 1 ] === '_') { + // This is not possible + if (count($segments) < 2) { + return RestoLogUtil::httpError(404); + } + + array_pop($segments); + $catalogs = $this->catalogsFunctions->getCatalogs(array( + 'id' => join('/', $segments) + )); + + if ( empty($catalogs) || !$catalogs[0]['hashtag'] ) { + return RestoLogUtil::httpError(404); + } + + $searchParams = array( + 'q' => '#' . $catalogs[0]['hashtag'] + ); + + foreach (array_keys($params) as $key) { + if ($key !== 'segments') { + $searchParams[$key] = $params[$key]; + } + } + + return $this->search($searchParams, null); + + } - return; + // The path is the catalog identifier + $parentAndChilds = $this->getParentAndChilds(join('/', $segments)); + return array( + 'stac_version' => STAC::STAC_VERSION, + 'id' => $segments[count($segments) -1 ], + 'title' => $parentAndChilds['parent']['title'] ?? '', + 'description' => $parentAndChilds['parent']['description'] ?? '', + 'type' => 'Catalog', + 'links' => array_merge( + $this->getBaseLinks($segments), + $parentAndChilds['childs'] + ) + ); } /** + * Process catalogs from addons i.e. not handled within resto.catalog table * + * @param array $segments + * @param array $params + * @return array */ private function processAddons($segments, $params) { $nbOfSegments = count($segments); - if ($this->segments[0] === 'views' && isset($this->context->addons['View'])) { + if ($segments[0] === 'views' && isset($this->context->addons['View'])) { $view = new View($this->context, $this->user); @@ -1081,7 +1076,7 @@ private function processAddons($segments, $params) // Individual view elseif ($nbOfSegments === 2) { return $view->getView(array_merge($this->context->query, array( - 'viewId' => $this->segments[1], + 'viewId' => $segments[1], 'format' => 'stac' ))); } @@ -1091,7 +1086,7 @@ private function processAddons($segments, $params) } // SOSA special case - else if ($this->segments[0] === 'concepts' && isset($this->context->addons['SOSA'])) { + else if ($segments[0] === 'concepts' && isset($this->context->addons['SOSA'])) { $skos = new SKOS($this->context, $this->user); // Root @@ -1101,7 +1096,7 @@ private function processAddons($segments, $params) // Individual concept elseif ($nbOfSegments === 2) { return $skos->getConcept(array_merge($this->context->query, array( - 'conceptId' => $this->segments[1], + 'conceptId' => $segments[1], ))); } else { @@ -1111,294 +1106,6 @@ private function processAddons($segments, $params) return null; } - /** - * 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'; - } - - /** - * Build catalog - * - * @param array $params - * @return array - */ - private function buildCatalog($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 = array(); - $whereValues = array(); - - // 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[] = $leafValue; - $where[] = 'type=$' . count($whereValues); - } - - } - - - // Hack for catalog - force a hierarchy - if ($this->segments[0] === 'catalogs') { - $whereValues[] = 'root'; - $where[] = 'public.normalize(pid)=public.normalize($' . count($whereValues). ')'; - } - - // Hack for landcover... - elseif ($this->segments[1] === 'landcover') { - - if ($nbOfSegments === 2) { - $whereValues[] = 'landcover:%'; - $where[] = 'type LIKE $' . count($whereValues); - } - } - - // 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 : 'public.normalize(pid)=public.normalize($1)') . ' GROUP BY id, value, pid, isleaf ORDER BY value ASC', $nbOfSegments === $leafPosition ? $whereValues : array($whereValues[0])); - $results = $this->context->dbDriver->pQuery('SELECT id, value, pid, isleaf, sum(counter) as matched FROM ' . $this->context->dbDriver->targetSchema . '.facet' . (count($where) > 0 ? ' WHERE ' . join(' AND ', $where) : '') . ' GROUP BY id, value, pid, isleaf ORDER BY value ASC', $whereValues); - - if (!$results) { - throw new Exception('Error', 500); - } - - // No Results - either a wrong path or a leaf facet (except for hashtag) - if (pg_num_rows($results) === 0) { - - // Catalog does not exist - if ($this->segments[0] === 'catalogs') { - throw new Exception('Not Found', 404); - } - - //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) { - RestoLogUtil::httpError($e->getCode(), $e->getMessage()); - } - } - - /** - * 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(RestoConstants::TAG_SEPARATOR, $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 @@ -1450,6 +1157,61 @@ private function jsonQueryToKVP($jsonQuery) } + /** + * Return catalog childs + * + * @param string $catalogId + * @return array + */ + private function getParentAndChilds($catalogId) + { + + // Get catalogs - first one is $catalogId, other its childs + $catalogs = $this->catalogsFunctions->getCatalogs(array( + 'id' => $catalogId + ), true); + + $parentAndChilds = array( + 'parent' => $catalogs[0], + 'childs' => array() + ); + + // Parent has no catalog childs, so its childs are item + if ( count($catalogs) === 1 ) { + $parentAndChilds['childs'] = $this->catalogsFunctions->getCatalogItems($parentAndChilds['parent']['id'], $this->context->core['baseUrl']); + } + else { + + // Parent has an hashtag thus can have a rel="items" child to directly search for its contents + if ( isset($parentAndChilds['parent']['hashtag']) ) { + $parentAndChilds['childs'][] = array( + 'rel' => 'items', + 'title' => $parentAndChilds['parent']['title'], + 'type' => RestoUtil::$contentTypes['geojson'], + 'href' => $this->context->core['baseUrl'] . '/catalogs/' . join('/', array_map('rawurlencode', explode('/', $parentAndChilds['parent']['id']))) . '/_', + 'matched' => $parentAndChilds['parent']['counters']['total'] + ); + } + + // Childs are 1 level above their parent catalog level + for ($i = 1, $ii = count($catalogs); $i < $ii; $i++) { + if ($catalogs[$i]['level'] === $parentAndChilds['parent']['level'] + 1) { + $parentAndChilds['childs'][] = array( + 'rel' => 'child', + 'title' => $catalogs[$i]['title'], + 'description' => $catalogs[$i]['description'] ?? '', + 'type' => RestoUtil::$contentTypes['json'], + 'href' => $this->context->core['baseUrl'] . '/catalogs/' . join('/', array_map('rawurlencode', explode('/', $catalogs[$i]['id']))), + 'matched' => $catalogs[$i]['counters']['total'] + ); + } + } + } + + return $parentAndChilds; + + } + /** * Return self/root/parent links * @@ -1505,7 +1267,7 @@ private function getRootCatalogLinks() } // Get first level catalog - $catalogs = (new CatalogsFunctions($this->context->dbDriver))->getCatalogs(array( + $catalogs = $this->catalogsFunctions->getCatalogs(array( 'level' => 1 )); diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index a95a2ddb..6630f52f 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -97,7 +97,7 @@ public function getCatalogs($params, $withChilds = false) if ( isset($params['where'])) { $where[] = $params['where']; } - + if ( isset($params['id']) ) { $values[] = $params['id']; $_where = 'public.normalize(id) = public.normalize($' . count($values) . ')'; @@ -118,14 +118,54 @@ public function getCatalogs($params, $withChilds = false) $where[] = 'level=$' . count($values); } - $results = $this->dbDriver->pQuery('SELECT id, title, description, level, counters, owner, links, visibility, rtype, hashtag, to_iso8601(created) as created FROM ' . $this->dbDriver->targetSchema . '.catalog' . ( empty($where) ? '' : ' WHERE ' . join(' AND ', $where) . ' ORDER BY id ASC'), $values); - while ($result = pg_fetch_assoc($results)) { - $catalogs[] = CatalogsFunctions::format($result); + try { + $results = $this->dbDriver->pQuery('SELECT id, title, description, level, counters, owner, links, visibility, rtype, hashtag, to_iso8601(created) as created FROM ' . $this->dbDriver->targetSchema . '.catalog' . ( empty($where) ? '' : ' WHERE ' . join(' AND ', $where) . ' ORDER BY id ASC'), $values); + while ($result = pg_fetch_assoc($results)) { + $catalogs[] = CatalogsFunctions::format($result); + } + } catch (Exception $e) { + RestoLogUtil::httpError(500, $e->getMessage()); } return $catalogs; } + /** + * Get catalog items as STAC links + * + * @param string $catalogId + * @param string $baseUrl + * @return array + */ + public function getCatalogItems($catalogId, $baseUrl) + { + + $items = []; + + /* + * Delete (within transaction) + */ + try { + $results = $this->dbDriver->pQuery('SELECT featureid, collection FROM ' . $this->dbDriver->targetSchema . '.catalog_feature WHERE catalogid=$1', array( + $catalogId + )); + } catch (Exception $e) { + RestoLogUtil::httpError(500, $e->getMessage()); + } + + while ($result = pg_fetch_assoc($results)) { + $items[] = array( + 'rel' => 'item', + 'id' => $result['featureid'], + 'type' => RestoUtil::$contentTypes['geojson'], + 'href' => $baseUrl . '/collections/' . $result['collection'] . '/items/' . $result['featureid'] + ); + } + + return $items; + + } + /** * Store catalogs within database * @@ -138,7 +178,7 @@ public function getCatalogs($params, $withChilds = false) */ public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) { - // Empty facets - do nothing + // Empty catalogs - do nothing if (!isset($catalogs) || count($catalogs) === 0) { return; } @@ -177,10 +217,11 @@ public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) // Feature is set => fill catalog_feature table if ( isset($featureId) && isset($catalog['hashtag']) ) { - $this->dbDriver->pQuery('INSERT INTO ' . $this->dbDriver->targetSchema . '.catalog_feature (featureid, catalogid, hashtag) VALUES ($1,$2,$3) ON CONFLICT (featureid, catalogid) DO NOTHING', array( + $this->dbDriver->pQuery('INSERT INTO ' . $this->dbDriver->targetSchema . '.catalog_feature (featureid, catalogid, hashtag, collection) VALUES ($1,$2,$3,$4) ON CONFLICT (featureid, catalogid) DO NOTHING', array( $featureId, $catalog['id'], - $catalog['hashtag'] + $catalog['hashtag'], + $collectionId ), 500, 'Cannot catalog_feature association ' . $catalog['id'] . '/' . $featureId); } diff --git a/resto-database-model/03_resto_target_model.sql b/resto-database-model/03_resto_target_model.sql index ff7d44a9..c761b056 100644 --- a/resto-database-model/03_resto_target_model.sql +++ b/resto-database-model/03_resto_target_model.sql @@ -471,6 +471,9 @@ CREATE TABLE IF NOT EXISTS __DATABASE_TARGET_SCHEMA__.catalog_feature ( -- Search hashtag hashtag TEXT, + -- Feature collection + collection TEXT, + PRIMARY KEY (featureid, catalogid) ); From 34946c74ecfced891a4bd0e8471e4e8c0ed9bed5 Mon Sep 17 00:00:00 2001 From: jjrom Date: Fri, 7 Jun 2024 11:13:25 +0200 Subject: [PATCH 12/27] Typo in SQL --- app/resto/core/dbfunctions/CatalogsFunctions.php | 1 - ...90611T160901_N0207_R140_T23XMD_20190611T193040_update.json | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 6630f52f..050c8649 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -288,7 +288,6 @@ public function updateCatalogsCounters($catalogs, $collectionId, $increment) $query = join(' ', array( 'UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET counters=public.increment_counters(counters,' . $increment . ',' . (isset($collectionId) ? '\'' . $collectionId . '\'': 'NULL') . ')', - 'FROM ' . $this->dbDriver->targetSchema . '.catalog', 'WHERE id IN (\'' . join('\',\'', $catalogIds) . '\') RETURNING id' )); diff --git a/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json b/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json index b0ab9061..48e7637a 100644 --- a/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json +++ b/examples/features/S2A_MSIL1C_20190611T160901_N0207_R140_T23XMD_20190611T193040_update.json @@ -124,6 +124,10 @@ "roles": [ "metadata" ] + }, + "wmts_test" : { + "href": "https://wmts.marine.copernicus.eu/teroWmts/GLOBAL_ANALYSISFORECAST_PHY_001_024/cmems_mod_glo_phy-thetao_anfc_0.083deg_P1D-m_202211", + "type": "OGC:WMTS" } } } \ No newline at end of file From 66f7ab2d0390b1325b7639eb6367cbc746058d7f Mon Sep 17 00:00:00 2001 From: jjrom Date: Sat, 8 Jun 2024 08:37:15 +0200 Subject: [PATCH 13/27] Update README in addon --- app/resto/core/RestoAddOn.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/resto/core/RestoAddOn.php b/app/resto/core/RestoAddOn.php index 43149949..d81d2ed3 100755 --- a/app/resto/core/RestoAddOn.php +++ b/app/resto/core/RestoAddOn.php @@ -38,7 +38,10 @@ class RestoAddOn protected $options; /* - * If set, options array is initialize from "$optionsFrom" add-on + * By default addon options is set from the addon name. + * If optionsFrom is set, then options array is set from the addon named "$optionsFrom" instead + * + * For an example, see https://github.com/jjrom/resto-addon-snapplanet) */ protected $optionsFrom = null; From 30b26de54d5fa9b4267714026d2a2a083e13cb9e Mon Sep 17 00:00:00 2001 From: jjrom Date: Tue, 11 Jun 2024 14:42:34 +0200 Subject: [PATCH 14/27] [UNTESTED] Add catalog with child update --- app/resto/core/addons/STACCatalog.php | 267 +++--------------- .../core/dbfunctions/CatalogsFunctions.php | 154 +++++++++- 2 files changed, 176 insertions(+), 245 deletions(-) diff --git a/app/resto/core/addons/STACCatalog.php b/app/resto/core/addons/STACCatalog.php index acca4432..ff38d2af 100644 --- a/app/resto/core/addons/STACCatalog.php +++ b/app/resto/core/addons/STACCatalog.php @@ -27,7 +27,7 @@ class STACCatalog extends RestoAddOn */ private $prefix = 'catalog:'; - private catalogsFunctions; + private $catalogsFunctions; /** * Constructor @@ -42,18 +42,13 @@ public function __construct($context, $user) } /** - * Add a catalog as a facet entry + * Add a catalog * * @OA\Post( * path="/catalogs/*", * summary="Add a STAC catalog", - * description="Add a STAC catalog as a facet entry", + * description="Add a STAC catalog", * tags={"STAC"}, - * @OA\Property( - * property="pid", - * type="string", - * description="Catalog parent id. Must be provided if input catalog has a rel=parent in links referencing a local path in href instead of an absolute url. In the latter case, the parent identifier is retrieved automatically by resolving the url." - * ), * @OA\RequestBody( * description="A valid STAC Catalog", * required=true, @@ -125,17 +120,29 @@ public function addCatalog($params, $body) $body['links'] = array(); } - return $this->storeCatalogAsFacet($body, $params['pid'] ?? null, $this->getChilds($body['links'])); + /* + * Convert input catalog to resto:catalog + * i.e. add rtype and hashtag properties and convert id to a path + */ + $body['rtype'] = 'catalog'; + $body['hashtag'] = 'catalog' . RestoConstants::TAG_SEPARATOR . $body['id']; + $body['id'] = $this->getIdPath($body); + + if ($this->catalogsFunctions->getCatalog($catalogId) !== null) { + RestoLogUtil::httpError(409, 'Catalog ' . end(explode('/', $body['id'])) . ' already exists'); + } + + return $this->catalogFunctions->storeCatalog($body, $this->user->profile['id'], null, null); } /** - * Update catalog as a facet entry + * Update catalog * * @OA\Put( - * path="/catalogs/catalogs/{catalogId}", + * path="/catalogs/{catalogId}", * summary="Update catalog", - * description="Update catalog as a facet entry", + * description="Update catalog", * tags={"STAC"}, * @OA\Parameter( * name="catalogId", @@ -328,244 +335,34 @@ public function removeCatalog($params) return RestoLogUtil::success('Catalog deleted', (new FacetsFunctions($this->context->dbDriver))->removeFacet($facetId)); } - - /** - * Check if catalog exists - * - * @param string $catalogId - * @throws Exception - */ - private function catalogExists($catalogId) - { - return !empty($this->catalogsFunctions->getCatalogs(array( - 'id' => $catalogId - ))); - } - + /** - * - * Store catalog as facet + * Return path identifier from input catlaog * * @param array $catalog - * @param string $parentId - * @param array $childs - * @return array - * - */ - private function storeCatalogAsFacet($catalog, $parentId, $childs) - { - - if ( !isset($parentId) ) { - $parentId = $this->parentIdFromLinks($catalog['links'] ?? array()); - } - - // Catalog is a leaf if it has no child - $isLeaf = empty($childs) ? true : false; - - /* - * Remove "catalog:" prefix from id - */ - if ( str_starts_with($catalog['id'], $this->prefix) ) { - $catalog['id'] = substr($catalog['id'], strlen($this->prefix)); - } - - /* - * Catalog already exist - */ - if ( $this->catalogExists($this->prefix . $catalog['id'], $parentId, '*') ) { - return RestoLogUtil::httpError(409, 'Catalog ' . $catalog['id'] . ' already exist'); - } - - /* - * Store catalog and update its child pid - */ - try { - - $this->context->dbDriver->query('BEGIN'); - - // 1. Store catalog - (new FacetsFunctions($this->context->dbDriver))->storeFacets(array( - array( - 'id' => $this->prefix . $catalog['id'], - 'parentId' => $parentId, - 'value' => $catalog['title'] ?? $catalog['id'], - 'type' => substr($this->prefix, 0, -1), - 'description' => $catalog['description'], - 'isLeaf' => $isLeaf, - 'counter' => 0 - ) - ), $this->user->profile['id']); - - // 2. Update childs pid to point to the catalog - for ($i = count($childs); $i--;) { - if ($childs[$i]['type'] === 'collection') { - (new FacetsFunctions($this->context->dbDriver))->storeFacets(array( - array( - 'id' => $childs[$i]['id'], - 'parentId' => $this->prefix . $catalog['id'], - 'value' => $childs[$i]['title'], - 'type' => 'collection', - 'isLeaf' => $childs[$i]['isLeaf'], - 'counter' => 0 - ) - ), $this->user->profile['id']); - } - else { - $this->context->dbDriver->pQuery('UPDATE ' . $this->context->dbDriver->targetSchema . '.facet SET pid=$2 WHERE public.normalize(id)=public.normalize($1) RETURNING id', array( - $childs[$i]['id'], - $this->prefix . $catalog['id'] - )); - } - } - - $this->context->dbDriver->query('COMMIT'); - - } catch (Exception $e) { - $this->context->dbDriver->query('ROLLBACK'); - RestoLogUtil::httpError(500, $e->getMessage()); - } - - try { - - } catch (Exception $e) { - return RestoLogUtil::httpError(500, 'Cannot insert catalog ' . $catalog['id']); - } - - return RestoLogUtil::success('Catalog ' . $catalog['id'] . ' created with parent ' . $parentId, array( - 'id' => $catalog['id'], - 'parentId' => $parentId, - 'childs' => $childs - )); - - } - - /** - * Resolve a link - * - * @param array $link * @return string */ - private function resolveLink($link) + private function getIdPath($catalog) { - // [IMPORTANT] Can only process http(s) urls not local file - if ( strpos(strtolower($link['href']), 'http') !== 0 ) { - return RestoLogUtil::httpError(400, 'Link href must be an url i.e. start with http(s)'); - } - - try { - $curl = new Curly(); - $resolved = json_decode($curl->get($link['href']), true); - if ( isset($resolved['ErrorCode']) ) { - throw new Exception(); - } - $curl->close(); - } catch (Exception $e) { - $curl->close(); - return null; - } - - return $resolved; - - } - - /** - * Return parent identifier from an array links - * - * @param array $links - * @return string - */ - private function parentIdFromLinks($links) - { + $parentId = ''; // Retrieve parent if any - for ($i = 0, $ii = count($links); $i < $ii; $i++ ) { - if ( isset($links[$i]['rel']) && $links[$i]['rel'] === 'parent' ) { - $parent = $this->resolveLink($links[$i]); - // To avoid egg and chicken issue, if the parent is not valid, we force a 'root' parent - if ( !isset($parent) || !isset($parent['id']) || !isset($parent['type']) ) { - break; + for ($i = 0, $ii = count($catalog['links']); $i < $ii; $i++ ) { + if ( isset($catalog['links'][$i]['rel']) &&$catalog['links'][$i]['rel'] === 'parent' ) { + $theoricalUrl = $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS; + $exploded = explode($theoricalUrl, $catalog['links'][$i]['href']); + if (count($exploded) !== 2) { + return RestoLogUtil::httpError(400, 'Parent link is set but it\'s url is invalid - should starts with ' . $theoricalUrl); } - return str_starts_with($parent['id'], $parent['type'] . ':') ? $parent['id'] : $parent['type'] . ':' . $parent['id']; + $parentId = str_starts_with($exploded[1], '/') ? substr($exploded[1], 1) : $exploded[1]; + break; } } - return 'root'; + return ($parentId === '' ? '' : $parentId . '/') . $catalog['id']; } - /** - * Check that : - * - links is an array - * - rel="child" links all exists in database - * - links does not contains rel="item" or rel="items" - */ - private function getChilds($links) - { - - $childs = array(); - - if ( !is_array($links) ) { - return RestoLogUtil::httpError(400, 'Invalid links array'); - } - - for ($i = count($links); $i--;) { - - if ($links[$i]['rel']) { - - if ( in_array($links[$i]['rel'], array('item', 'items')) ) { - return RestoLogUtil::httpError(400, 'Links array should not contains rel type "item" or "items"'); - } - - if ( $links[$i]['rel'] === 'child' ) { - - $resolved = $this->resolveLink($links[$i]); - - if ( ! isset($resolved) ) { - return RestoLogUtil::httpError(400, 'Child with href ' . $links[$i]['href'] . ' does not exist in database'); - } - - if ( ! isset($resolved['id']) ) { - return RestoLogUtil::httpError(400, 'Link with href ' . $links[$i]['href'] . ' is missing mandatory id'); - } - - if ( ! isset($resolved['type']) || !in_array($resolved['type'], array('Catalog', 'Collection')) ) { - return RestoLogUtil::httpError(400, 'Link with href ' . $links[$i]['href'] . ' has an invalid type'); - } - - if ( $resolved['type'] === 'Catalog' ) { - $catalogId = str_starts_with($resolved['id'], $this->prefix) ? $resolved['id'] : $this->prefix . $resolved['id']; - if ( !$this->catalogExists($catalogId, null, null) ) { - return RestoLogUtil::httpError(404, 'Child catalog ' . $catalogId . ' not found'); - } - $childs[] = array( - 'id' => $catalogId, - 'type' => 'catalog', - ); - } - else if ( $resolved['type'] === 'Collection' ) { - $collectionsFunctions = new CollectionsFunctions($this->context->dbDriver); - if ( $collectionsFunctions->collectionExists($resolved['id']) || $collectionsFunctions->collectionExists($collectionsFunctions->aliasToCollectionId($resolved['id'])) ) { - $childs[] = array( - 'id' => 'collection:' . $resolved['id'], - 'title' => $resolved['id'], - 'isLeaf' => 1, - 'type' => 'collection', - ); - } - else { - return RestoLogUtil::httpError(404, 'Child collection ' . $resolved['id'] . ' not found'); - } - - } - } - - } - - } - - return $childs; - - } } diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 050c8649..0ae66e59 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -79,10 +79,30 @@ public static function format($rawCatalog) ); } + /** + * Get catalog + * + * @param string $id + */ + public function getCatalog($catalogId) + { + + $catalogs = $this->getCatalogs(array( + 'id' => $catalogId + )); + + if ( isset($catalogs) && count($catalogs) === 1) { + return $catalogs[0]; + } + + return null; + + } + /** * Get catalogs (and eventually all its childs if id is set) * - * @param array $params + * @param string $params * @param boolean $withChilds */ public function getCatalogs($params, $withChilds = false) @@ -167,19 +187,19 @@ public function getCatalogItems($catalogId, $baseUrl) } /** - * Store catalogs within database + * Store catalog within database * * !! THIS FUNCTION IS THREAD SAFE !! * - * @param array $catalogs + * @param array $catalog * @param string $userid * @param string $collectionId * @param string $featureId */ - public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) + public function storeCatalog($catalog, $userid, $collectionId, $featureId) { - // Empty catalogs - do nothing - if (!isset($catalogs) || count($catalogs) === 0) { + // Empty catalog - do nothing + if (!isset($catalog)) { return; } @@ -192,9 +212,11 @@ public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) $counters['collections'][$collectionId] = 1; } - for ($i = count($catalogs); $i--;) { - - $catalog = $catalogs[$i]; + $cleanLinks = $this->getCleanLinks($catalog, $userid); + + try { + + $this->dbDriver->query('BEGIN'); /* * Thread safe ingestion using upsert - guarantees that counter is correctly incremented during concurrent transactions @@ -209,7 +231,7 @@ public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) // If no input counter is specified - set to 1 json_encode($counters, JSON_UNESCAPED_SLASHES), $catalog['owner'] ?? $userid, - isset($catalog['links']) ? json_encode($catalog['links'], JSON_UNESCAPED_SLASHES) : null, + json_encode($cleanLinks['links'], JSON_UNESCAPED_SLASHES), RestoConstants::GROUP_DEFAULT_ID, $catalog['rtype'] ?? null, $catalog['hashtag'] ?? null @@ -224,8 +246,57 @@ public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) $collectionId ), 500, 'Cannot catalog_feature association ' . $catalog['id'] . '/' . $featureId); } + + /* + * Now the tricky part - change catalogs level + */ + for ($i = 0, $ii = count($cleanLinks['toUpdate']); $i < $ii; $i++) { + $toUpdateLink = $cleanLinks['toUpdate'][$i]; + $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET id=$2, level=level + 1 WHERE id=$1', array( + $toUpdateLink['id'], + $catalog['id'] . '/' . $toUpdateLink['id'] + ), 500, 'Cannot update child link ' . $toUpdateLink['id']); + $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog_feature SET catalogid=$2 WHERE catalogid=$1', array( + $toUpdateLink['id'], + $catalog['id'] . '/' . $toUpdateLink['id'] + ), 500, 'Cannot update catalog feature association for child link ' . $toUpdateLink['id']); + } + + $this->dbDriver->query('COMMIT'); + + } catch (Exception $e) { + $this->dbDriver->query('ROLLBACK'); + RestoLogUtil::httpError(500, $e->getMessage()); + } + + return $catalog; + + } + + + /** + * Store catalogs within database + * + * !! THIS FUNCTION IS THREAD SAFE !! + * + * @param array $catalogs + * @param string $userid + * @param string $collectionId + * @param string $featureId + */ + public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) + { + // Empty catalogs - do nothing + if (!isset($catalogs) || count($catalogs) === 0) { + return array(); + } + for ($i = count($catalogs); $i--;) { + $this->storeCatalog($catalog, $userid, $collectionId, $featureId); } + + return $catalogs; + } /** @@ -564,4 +635,67 @@ public function diff($oldCatalogs, $newCatalogs) } + /** + * Return a "cleaned" list of catalog links. + * Cleaned list means : + * - discard root, parent and self links + * - move first level child links that belong to user to update array + * - keep non first level child links + * + * @param array $catalog + * @return array + */ + private function getCleanLinks($catalog, $userid) { + + $output = array( + 'links' => array(), + 'toUpdate' => array() + ); + + if ( !isset($catalog['links']) ) { + return $output; + } + + for ($i = 0, $ii = count($catalog['links']); $i < $ii; $i++) { + $link = $catalog['links'][$i]; + if ( !isset($link['rel']) || in_array($link['rel'], array('root', 'parent', 'self')) ) { + continue; + } + + if ( in_array($link['rel'], array('item', 'items')) ) { + return RestoLogUtil::httpError(400, 'Catalog cannot contains item or items rel'); + } + + if ( $link['rel'] === 'child') { + if ( !isset($link['href']) ) { + return RestoLogUtil::httpError(400, 'One link child has an empty href'); + } + if (str_starts_with($link['href'], $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_COLLECTIONS )) { + $output['links'][] = $link; + continue; + } + $exploded = explode($this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS . '/', $link['href']); + if ( count($explode) !== 2) { + return RestoLogUtil::httpError(400, 'One link child has a external href i.e. not starting with ' . $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS); + } + $childCatalog = $this->getCatalog($catalog['id']); + if ( $childCatalog === null ) { + return RestoLogUtil::httpError(400, 'Catalog child ' . $link['href'] . ' does not exist in database'); + } + + if ($childCatalog['level'] === 1 && $childCatalog['owner'] === $userid) { + $output['toUpdate'][] = $link; + } + else { + $output['links'][] = $link; + } + + } + + } + + return $output; + + } + } From fa607511994ec001ca00d5fe04cfdf2a53e3e8ee Mon Sep 17 00:00:00 2001 From: jjrom Date: Tue, 11 Jun 2024 16:34:48 +0200 Subject: [PATCH 15/27] Add catalog works --- app/resto/core/addons/STACCatalog.php | 9 ++-- .../core/dbfunctions/CatalogsFunctions.php | 52 +++++++++++-------- .../core/dbfunctions/FeaturesFunctions.php | 6 +-- app/resto/core/utils/RestoUtil.php | 8 +++ examples/catalogs/dummyCatalogWithChilds.json | 6 +-- 5 files changed, 50 insertions(+), 31 deletions(-) diff --git a/app/resto/core/addons/STACCatalog.php b/app/resto/core/addons/STACCatalog.php index ff38d2af..1871ca94 100644 --- a/app/resto/core/addons/STACCatalog.php +++ b/app/resto/core/addons/STACCatalog.php @@ -128,11 +128,12 @@ public function addCatalog($params, $body) $body['hashtag'] = 'catalog' . RestoConstants::TAG_SEPARATOR . $body['id']; $body['id'] = $this->getIdPath($body); - if ($this->catalogsFunctions->getCatalog($catalogId) !== null) { - RestoLogUtil::httpError(409, 'Catalog ' . end(explode('/', $body['id'])) . ' already exists'); + if ($this->catalogsFunctions->getCatalog($body['id']) !== null) { + + RestoLogUtil::httpError(409, 'Catalog ' . $body['id'] . ' already exists'); } - - return $this->catalogFunctions->storeCatalog($body, $this->user->profile['id'], null, null); + $baseUrl = $this->context->core['baseUrl']; + return $this->catalogsFunctions->storeCatalog($body, $this->user->profile['id'], $baseUrl, null, null); } diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 0ae66e59..8b90a7ac 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -193,10 +193,11 @@ public function getCatalogItems($catalogId, $baseUrl) * * @param array $catalog * @param string $userid + * @param string $baseUrl * @param string $collectionId * @param string $featureId */ - public function storeCatalog($catalog, $userid, $collectionId, $featureId) + public function storeCatalog($catalog, $userid, $baseUrl, $collectionId, $featureId) { // Empty catalog - do nothing if (!isset($catalog)) { @@ -212,7 +213,7 @@ public function storeCatalog($catalog, $userid, $collectionId, $featureId) $counters['collections'][$collectionId] = 1; } - $cleanLinks = $this->getCleanLinks($catalog, $userid); + $cleanLinks = $this->getCleanLinks($catalog, $userid, $baseUrl); try { @@ -250,16 +251,16 @@ public function storeCatalog($catalog, $userid, $collectionId, $featureId) /* * Now the tricky part - change catalogs level */ - for ($i = 0, $ii = count($cleanLinks['toUpdate']); $i < $ii; $i++) { - $toUpdateLink = $cleanLinks['toUpdate'][$i]; + for ($i = 0, $ii = count($cleanLinks['updateCatalogs']); $i < $ii; $i++) { + $updateCatalogs = $cleanLinks['updateCatalogs'][$i]; $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET id=$2, level=level + 1 WHERE id=$1', array( - $toUpdateLink['id'], - $catalog['id'] . '/' . $toUpdateLink['id'] - ), 500, 'Cannot update child link ' . $toUpdateLink['id']); + $updateCatalogs['id'], + $catalog['id'] . '/' . $updateCatalogs['id'] + ), 500, 'Cannot update child link ' . $updateCatalogs['id']); $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog_feature SET catalogid=$2 WHERE catalogid=$1', array( - $toUpdateLink['id'], - $catalog['id'] . '/' . $toUpdateLink['id'] - ), 500, 'Cannot update catalog feature association for child link ' . $toUpdateLink['id']); + $updateCatalogs['id'], + $catalog['id'] . '/' . $updateCatalogs['id'] + ), 500, 'Cannot update catalog feature association for child link ' . $updateCatalogs['id']); } $this->dbDriver->query('COMMIT'); @@ -281,18 +282,25 @@ public function storeCatalog($catalog, $userid, $collectionId, $featureId) * * @param array $catalogs * @param string $userid - * @param string $collectionId + * @param string $baseUrl + * @param RestoCollection $collection * @param string $featureId */ - public function storeCatalogs($catalogs, $userid, $collectionId, $featureId) + public function storeCatalogs($catalogs, $userid, $baseUrl, $collection, $featureId) { // Empty catalogs - do nothing if (!isset($catalogs) || count($catalogs) === 0) { return array(); } + $collectionId = null; + $baseUrl = null; + if (isset($collection)) { + $collectionId = $collection->id; + $baseUrl = $collection->context->core['baseUrl']; + } for ($i = count($catalogs); $i--;) { - $this->storeCatalog($catalog, $userid, $collectionId, $featureId); + $this->storeCatalog($catalog, $userid, $baseUrl, $collectionId, $featureId); } return $catalogs; @@ -643,13 +651,15 @@ public function diff($oldCatalogs, $newCatalogs) * - keep non first level child links * * @param array $catalog + * @param string userid + * @param string $baseUrl * @return array */ - private function getCleanLinks($catalog, $userid) { + private function getCleanLinks($catalog, $userid, $baseUrl) { $output = array( 'links' => array(), - 'toUpdate' => array() + 'updateCatalogs' => array() ); if ( !isset($catalog['links']) ) { @@ -670,21 +680,21 @@ private function getCleanLinks($catalog, $userid) { if ( !isset($link['href']) ) { return RestoLogUtil::httpError(400, 'One link child has an empty href'); } - if (str_starts_with($link['href'], $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_COLLECTIONS )) { + if (str_starts_with($link['href'], $baseUrl . RestoRouter::ROUTE_TO_COLLECTIONS )) { $output['links'][] = $link; continue; } - $exploded = explode($this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS . '/', $link['href']); - if ( count($explode) !== 2) { - return RestoLogUtil::httpError(400, 'One link child has a external href i.e. not starting with ' . $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS); + $exploded = explode($baseUrl . RestoRouter::ROUTE_TO_CATALOGS . '/', $link['href']); + if ( count($exploded) !== 2) { + return RestoLogUtil::httpError(400, 'One link child has an external href i.e. not starting with ' . $baseUrl . RestoRouter::ROUTE_TO_CATALOGS); } - $childCatalog = $this->getCatalog($catalog['id']); + $childCatalog = $this->getCatalog($exploded[1]); if ( $childCatalog === null ) { return RestoLogUtil::httpError(400, 'Catalog child ' . $link['href'] . ' does not exist in database'); } if ($childCatalog['level'] === 1 && $childCatalog['owner'] === $userid) { - $output['toUpdate'][] = $link; + $output['updateCatalogs'][] = $childCatalog; } else { $output['links'][] = $link; diff --git a/app/resto/core/dbfunctions/FeaturesFunctions.php b/app/resto/core/dbfunctions/FeaturesFunctions.php index 64e17c8a..0d350a48 100755 --- a/app/resto/core/dbfunctions/FeaturesFunctions.php +++ b/app/resto/core/dbfunctions/FeaturesFunctions.php @@ -349,7 +349,7 @@ public function storeFeature($id, $collection, $featureArray) /* * Store catalogs */ - (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysAndValues['catalogs'], $collection->user->profile['id'], $collection->id, $result['id']); + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($keysAndValues['catalogs'], $collection->user->profile['id'], $collection, $result['id']); /* * Commit everything - rollback if one of the inserts failed @@ -510,7 +510,7 @@ public function updateFeature($feature, $collection, $newFeatureArray) )); } if ( !empty($diffCatalogs['added']) ) { - (new CatalogsFunctions($this->dbDriver))->storeCatalogs($diffCatalogs['added'], $collection->user->profile['id'], $collection->id, $feature->id); + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($diffCatalogs['added'], $collection->user->profile['id'], $collection, $feature->id); } /* @@ -604,7 +604,7 @@ public function updateFeatureDescription($feature, $description) )); } if ( !empty($diffCatalogs['added']) ) { - (new CatalogsFunctions($this->dbDriver))->storeCatalogs($diffCatalogs['added'], $feature->collection->user->profile['id'], $feature->collection->id, $feature->id); + (new CatalogsFunctions($this->dbDriver))->storeCatalogs($diffCatalogs['added'], $feature->collection->user->profile['id'], $feature->collection, $feature->id); } } catch (Exception $e) { diff --git a/app/resto/core/utils/RestoUtil.php b/app/resto/core/utils/RestoUtil.php index 8774e1e9..221de5d8 100755 --- a/app/resto/core/utils/RestoUtil.php +++ b/app/resto/core/utils/RestoUtil.php @@ -502,6 +502,14 @@ public static function checkUser($user, $userid) } } + /** + * Convert a $href url with host.docker.internal address to baseUrl + */ + public static function localhostToDockerhost($url) + { + return str_replace(array('//127.0.0.1', '//localhost'), '//host.docker.internal', $url); + } + /** * Construct base url from parse_url fragments * diff --git a/examples/catalogs/dummyCatalogWithChilds.json b/examples/catalogs/dummyCatalogWithChilds.json index ccce14c2..3283dd92 100644 --- a/examples/catalogs/dummyCatalogWithChilds.json +++ b/examples/catalogs/dummyCatalogWithChilds.json @@ -7,15 +7,15 @@ "links":[ { "rel":"child", - "href":"http://host.docker.internal:5252/catalogs/catalogs/catalog:dummyCatalogChild1" + "href":"http://127.0.0.1:5252/catalogs/dummyCatalogChild1" }, { "rel":"child", - "href":"http://host.docker.internal:5252/catalogs/catalogs/catalog:dummyCatalogChild2" + "href":"http://127.0.0.1:5252/catalogs/dummyCatalogChild2" }, { "rel":"child", - "href":"http://host.docker.internal:5252/collections/S2" + "href":"http://127.0.0.1:5252/collections/S2" } ] } \ No newline at end of file From 5d0d460f8ced2f50273dd3918e705f17a54f7c9a Mon Sep 17 00:00:00 2001 From: jjrom Date: Tue, 11 Jun 2024 16:52:45 +0200 Subject: [PATCH 16/27] Remove catalog works --- app/resto/core/addons/STACCatalog.php | 41 +++++++++++++-------------- docs/COLLECTIONS_AND_CATALOGS.md | 2 +- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/app/resto/core/addons/STACCatalog.php b/app/resto/core/addons/STACCatalog.php index 1871ca94..b48dee3b 100644 --- a/app/resto/core/addons/STACCatalog.php +++ b/app/resto/core/addons/STACCatalog.php @@ -129,11 +129,10 @@ public function addCatalog($params, $body) $body['id'] = $this->getIdPath($body); if ($this->catalogsFunctions->getCatalog($body['id']) !== null) { - RestoLogUtil::httpError(409, 'Catalog ' . $body['id'] . ' already exists'); } $baseUrl = $this->context->core['baseUrl']; - return $this->catalogsFunctions->storeCatalog($body, $this->user->profile['id'], $baseUrl, null, null); + return RestoLogUtil::success('Catalog added', $this->catalogsFunctions->storeCatalog($body, $this->user->profile['id'], $baseUrl, null, null)); } @@ -236,12 +235,12 @@ public function updateCatalog($params, $body) } /** - * Delete catalog as a facet entry + * Delete catalog * * @OA\Delete( - * path="/catalogs/catalogs/{catalogId}", + * path="/catalogs/*", * summary="Delete catalog", - * description="Delete catalog as a facet entry - update feature keywords accordingly", + * description="Delete catalog", * tags={"STAC"}, * @OA\Parameter( * name="catalogId", @@ -307,34 +306,32 @@ public function updateCatalog($params, $body) public function removeCatalog($params) { - $facetId = $params['segments'][count($params['segments']) - 1 ]; - $facets = (new FacetsFunctions($this->context->dbDriver))->getFacets(array('id' => $facetId)); + // Get catalogs and childs + $catalogs = $this->catalogsFunctions->getCatalogs(array( + 'id' => join('/', $params['segments']) + ), true); - if ( empty($facets) ) { - return RestoLogUtil::httpError(404); + if ( count($catalogs) === 0 ){ + RestoLogUtil::httpError(404); } - // If user has not the right to delete ALL facets then 403 - for ($i = count($facets); $i--;) { - if ( !$this->user->hasRightsTo(RestoUser::DELETE_CATALOG, array('catalog' => $facets[$i])) ) { - return RestoLogUtil::httpError(403); - } + // If user has not the right to delete catalog then 403 + if ( !$this->user->hasRightsTo(RestoUser::DELETE_CATALOG, array('catalog' => $catalogs[0])) ) { + return RestoLogUtil::httpError(403); } - + // If catalog has childs it cannot be removed - $childs = (new FacetsFunctions($this->context->dbDriver))->getFacets(array('pid' => $facetId)); - if ( !empty($childs) ) { + for ($i = 1, $ii = count($catalogs); $i < $ii; $i--) { if (isset($params['force']) && filter_var($params['force'], FILTER_VALIDATE_BOOLEAN) === true) { return RestoLogUtil::httpError(400, 'TODO - force removal of non empty catalog is not implemented'); } else { - return RestoLogUtil::httpError(400, 'The catalog cannot be deleted because it has ' . count($childs) . ' childs'); - } - + return RestoLogUtil::httpError(400, 'The catalog cannot be deleted because it has ' . (count($catalogs) - 1) . ' childs'); + } } - return RestoLogUtil::success('Catalog deleted', (new FacetsFunctions($this->context->dbDriver))->removeFacet($facetId)); - + return RestoLogUtil::success('Catalog deleted', $this->catalogsFunctions->removeCatalog($catalogs[0]['id'])); + } /** diff --git a/docs/COLLECTIONS_AND_CATALOGS.md b/docs/COLLECTIONS_AND_CATALOGS.md index 64d47bc8..a84c1623 100644 --- a/docs/COLLECTIONS_AND_CATALOGS.md +++ b/docs/COLLECTIONS_AND_CATALOGS.md @@ -67,4 +67,4 @@ Only "title", "description" and "owner" properties can be updated ### Delete a catalog # Delete a catalog - user with the "deleteCatalog" right can delete a catalog he owns - curl -X DELETE "http://admin:admin@localhost:5252/catalogs/catalogs/catalog:dummyCatalog" \ No newline at end of file + curl -X DELETE "http://admin:admin@localhost:5252/catalogs/dummyCatalog" \ No newline at end of file From a88df8c655a127bc5a6ac1c0861b62cc16c8ff51 Mon Sep 17 00:00:00 2001 From: jjrom Date: Tue, 11 Jun 2024 17:07:17 +0200 Subject: [PATCH 17/27] Update catalog work --- app/resto/core/addons/STACCatalog.php | 35 +++++++------------ .../core/dbfunctions/CatalogsFunctions.php | 5 +-- docs/COLLECTIONS_AND_CATALOGS.md | 2 +- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/resto/core/addons/STACCatalog.php b/app/resto/core/addons/STACCatalog.php index b48dee3b..6f01fe84 100644 --- a/app/resto/core/addons/STACCatalog.php +++ b/app/resto/core/addons/STACCatalog.php @@ -21,10 +21,6 @@ class STACCatalog extends RestoAddOn { - /** - * Catalog prefix is systematically added internally - * to catalog identifier - */ private $prefix = 'catalog:'; private $catalogsFunctions; @@ -140,7 +136,7 @@ public function addCatalog($params, $body) * Update catalog * * @OA\Put( - * path="/catalogs/{catalogId}", + * path="/catalogs/*", * summary="Update catalog", * description="Update catalog", * tags={"STAC"}, @@ -204,34 +200,29 @@ public function addCatalog($params, $body) public function updateCatalog($params, $body) { - $facetId = $params['segments'][count($params['segments']) - 1 ]; - $facets = (new FacetsFunctions($this->context->dbDriver))->getFacets(array('id' => $facetId)); + // Get catalogs and childs + $catalogs = $this->catalogsFunctions->getCatalogs(array( + 'id' => join('/', $params['segments']) + ), true); - if ( empty($facets) ) { - return RestoLogUtil::httpError(404); + if ( count($catalogs) === 0 ){ + RestoLogUtil::httpError(404); } - // If user has not the right to update ALL facets then 403 - for ($i = count($facets); $i--;) { - if ( !$this->user->hasRightsTo(RestoUser::UPDATE_CATALOG, array('catalog' => $facets[$i])) ) { - return RestoLogUtil::httpError(403); - } - } - // Only title and description can be updated - $newFacet = array( - 'id' => $facetId - ); + // If user has not the right to update catalog then 403 + if ( !$this->user->hasRightsTo(RestoUser::UPDATE_CATALOG, array('catalog' => $catalogs[0])) ) { + return RestoLogUtil::httpError(403); + } $updatable = array('title', 'description', 'owner'); for ($i = count($updatable); $i--;) { if ( isset($body[$updatable[$i]]) ) { - $newFacet[$updatable[$i] === 'title' ? 'value' : $updatable[$i]] = $body[$updatable[$i]]; + $catalogs[0][$updatable[$i]] = $body[$updatable[$i]]; } } - - return RestoLogUtil::success('Catalog updated', (new FacetsFunctions($this->context->dbDriver))->updateFacet($newFacet)); + return RestoLogUtil::success('Catalog updated', $this->catalogsFunctions->updateCatalog($catalogs[0])); } /** diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index 8b90a7ac..d3904ca1 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -331,7 +331,7 @@ public function updateCatalog($catalog) $set = array(); foreach (array_keys($catalog) as $key ) { if (in_array($key, $canBeUpdated)) { - $values[] = $catalog[$key]; + $values[] = $key === 'links' ? json_encode($catalog[$key], JSON_UNESCAPED_SLASHES) : $catalog[$key]; $set[] = $key . '=$' . count($values); } } @@ -341,7 +341,8 @@ public function updateCatalog($catalog) 'catalogsUpdated' => 0 ); } - + print_r($set); + print_r($values); $results = $this->dbDriver->fetch($this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET ' . join(',', $set) . ' WHERE public.normalize(id)=public.normalize($1) RETURNING id', $values, 500, 'Cannot update facet ' . $catalog['id'])); return array( diff --git a/docs/COLLECTIONS_AND_CATALOGS.md b/docs/COLLECTIONS_AND_CATALOGS.md index a84c1623..308c1790 100644 --- a/docs/COLLECTIONS_AND_CATALOGS.md +++ b/docs/COLLECTIONS_AND_CATALOGS.md @@ -62,7 +62,7 @@ Then get the feature : Only "title", "description" and "owner" properties can be updated # Update a catalog - user with the "updateCatalog" right can update a catalog he owns - curl -X PUT -d@examples/catalogs/dummyCatalog_update.json "http://admin:admin@localhost:5252/catalogs/catalogs/catalog:dummyCatalog" + curl -X PUT -d@examples/catalogs/dummyCatalog_update.json "http://admin:admin@localhost:5252/catalogs/dummyCatalog" ### Delete a catalog From 267a6d42661c1115743df8fae39b85b01c578aa7 Mon Sep 17 00:00:00 2001 From: jjrom Date: Tue, 11 Jun 2024 17:12:00 +0200 Subject: [PATCH 18/27] Move STACCatalog functions to STAC class --- app/resto/core/RestoRouter.php | 8 +- app/resto/core/addons/STAC.php | 317 ++++++++++++++++ app/resto/core/addons/STACCatalog.php | 357 ------------------ .../core/dbfunctions/CatalogsFunctions.php | 3 +- 4 files changed, 321 insertions(+), 364 deletions(-) delete mode 100644 app/resto/core/addons/STACCatalog.php diff --git a/app/resto/core/RestoRouter.php b/app/resto/core/RestoRouter.php index 3bf81229..1d273c27 100755 --- a/app/resto/core/RestoRouter.php +++ b/app/resto/core/RestoRouter.php @@ -119,11 +119,9 @@ class RestoRouter array('GET', RestoRouter::ROUTE_TO_STAC_QUERYABLES, false, 'STAC::getQueryables'), // STAC/OAFeature API - Queryables array('GET', RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STAC::search'), // STAC API - core search (GET) array('POST', RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STAC::search'), // STAC API - core search (POST) - - // STAC Catalog - array('POST', RestoRouter::ROUTE_TO_CATALOGS , true , 'STACCatalog::addCatalog'), // STAC - Add a catalog - array('PUT' , RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STACCatalog::updateCatalog'), // STAC - Update a catalog - array('DELETE', RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STACCatalog::removeCatalog') // STAC - Remove a catalog + array('POST', RestoRouter::ROUTE_TO_CATALOGS , true , 'STAC::addCatalog'), // STAC - Add a catalog + array('PUT' , RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STAC::updateCatalog'), // STAC - Update a catalog + array('DELETE', RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STAC::removeCatalog') // STAC - Remove a catalog ); /* diff --git a/app/resto/core/addons/STAC.php b/app/resto/core/addons/STAC.php index 0c4c0e5d..d6596c24 100644 --- a/app/resto/core/addons/STAC.php +++ b/app/resto/core/addons/STAC.php @@ -289,6 +289,295 @@ public function getCatalogs($params) return $this->processPath($params['segments'], $params); } + /** + * Add a catalog + * + * @OA\Post( + * path="/catalogs/*", + * summary="Add a STAC catalog", + * description="Add a STAC catalog", + * tags={"STAC"}, + * @OA\RequestBody( + * description="A valid STAC Catalog", + * required=true, + * @OA\JsonContent(ref="#/components/schemas/Catalog") + * ), + * @OA\Response( + * response="200", + * description="The catalog is created", + * @OA\JsonContent( + * @OA\Property( + * property="status", + * type="string", + * description="Status is *success*" + * ), + * @OA\Property( + * property="message", + * type="string", + * description="Message information" + * ), + * example={ + * "status": "success", + * "message": "Catalog created" + * } + * ) + * ), + * @OA\Response( + * response="400", + * description="Missing one of the mandatory input property", + * @OA\JsonContent(ref="#/components/schemas/BadRequestError") + * ), + * @OA\Response( + * response="401", + * description="Unauthorized", + * @OA\JsonContent(ref="#/components/schemas/UnauthorizedError") + * ), + * @OA\Response( + * response="403", + * description="Only user with *createCatalog* rights can create a catalog", + * @OA\JsonContent(ref="#/components/schemas/ForbiddenError") + * ), + * security={ + * {"basicAuth":{}, "bearerAuth":{}, "queryAuth":{}} + * } + * ) + * + * @param array $params + * @param array $body + */ + public function addCatalog($params, $body) + { + + if (!$this->user->hasRightsTo(RestoUser::CREATE_CATALOG)) { + return RestoLogUtil::httpError(403); + } + + /* + * Check mandatory properties + */ + /*if ( isset($body['stac_version']) ) { + return RestoLogUtil::httpError(400, 'Missing mandatory catalog stac_version - should be set to ' . STAC::STAC_VERSION ); + }*/ + if ( !isset($body['id']) ) { + return RestoLogUtil::httpError(400, 'Missing mandatory catalog id'); + } + if ( !isset($body['description']) ) { + return RestoLogUtil::httpError(400, 'Missing mandatory description'); + } + if ( !isset($body['links']) ) { + $body['links'] = array(); + } + + /* + * Convert input catalog to resto:catalog + * i.e. add rtype and hashtag properties and convert id to a path + */ + $body['rtype'] = 'catalog'; + $body['hashtag'] = 'catalog' . RestoConstants::TAG_SEPARATOR . $body['id']; + $body['id'] = $this->getIdPath($body); + + if ($this->catalogsFunctions->getCatalog($body['id']) !== null) { + RestoLogUtil::httpError(409, 'Catalog ' . $body['id'] . ' already exists'); + } + $baseUrl = $this->context->core['baseUrl']; + return RestoLogUtil::success('Catalog added', $this->catalogsFunctions->storeCatalog($body, $this->user->profile['id'], $baseUrl, null, null)); + + } + + /** + * Update catalog + * + * @OA\Put( + * path="/catalogs/*", + * summary="Update catalog", + * description="Update catalog", + * tags={"STAC"}, + * @OA\Parameter( + * name="catalogId", + * in="path", + * required=true, + * description="Catalog identifier", + * @OA\Schema( + * type="string" + * ) + * ), + * @OA\RequestBody( + * description="Catalog fields to be update limited to title and description", + * required=true, + * @OA\JsonContent(ref="#/components/schemas/Catalog") + * ), + * @OA\Response( + * response="200", + * description="Catalog is updated", + * @OA\JsonContent( + * @OA\Property( + * property="status", + * type="string", + * description="Status is *success*" + * ), + * @OA\Property( + * property="message", + * type="string", + * description="Message information" + * ), + * example={ + * "status": "success", + * "message": "Catalog updated" + * } + * ) + * ), + * @OA\Response( + * response="404", + * description="Not found", + * @OA\JsonContent(ref="#/components/schemas/NotFoundError") + * ), + * @OA\Response( + * response="401", + * description="Unauthorized", + * @OA\JsonContent(ref="#/components/schemas/UnauthorizedError") + * ), + * @OA\Response( + * response="403", + * description="Only user with *updateCatalog* rights can update a catalog", + * @OA\JsonContent(ref="#/components/schemas/ForbiddenError") + * ), + * security={ + * {"basicAuth":{}, "bearerAuth":{}, "queryAuth":{}} + * } + * ) + * + * @param array $params + * @param array $body + */ + public function updateCatalog($params, $body) + { + + // Get catalogs and childs + $catalogs = $this->catalogsFunctions->getCatalogs(array( + 'id' => join('/', $params['segments']) + ), true); + + if ( count($catalogs) === 0 ){ + RestoLogUtil::httpError(404); + } + + + // If user has not the right to update catalog then 403 + if ( !$this->user->hasRightsTo(RestoUser::UPDATE_CATALOG, array('catalog' => $catalogs[0])) ) { + return RestoLogUtil::httpError(403); + } + + $updatable = array('title', 'description', 'owner'); + for ($i = count($updatable); $i--;) { + if ( isset($body[$updatable[$i]]) ) { + $catalogs[0][$updatable[$i]] = $body[$updatable[$i]]; + } + } + + return RestoLogUtil::success('Catalog updated', $this->catalogsFunctions->updateCatalog($catalogs[0])); + } + + /** + * Delete catalog + * + * @OA\Delete( + * path="/catalogs/*", + * summary="Delete catalog", + * description="Delete catalog", + * tags={"STAC"}, + * @OA\Parameter( + * name="catalogId", + * in="path", + * required=true, + * description="Catalog identifier", + * @OA\Schema( + * type="string" + * ) + * ), + * @OA\Parameter( + * name="force", + * in="query", + * description="Force catalog removal even if this catalog has child. In this case, catalogs childs are attached to the remove catalog parent", + * @OA\Schema( + * type="boolean" + * ) + * ), + * @OA\Response( + * response="200", + * description="Catalog deleted", + * @OA\JsonContent( + * @OA\Property( + * property="status", + * type="string", + * description="Status is *success*" + * ), + * @OA\Property( + * property="message", + * type="string", + * description="Message information" + * ), + * example={ + * "status": "success", + * "message": "Catalog deleted", + * "featuresUpdated": 345 + * } + * ) + * ), + * @OA\Response( + * response="404", + * description="Not found", + * @OA\JsonContent(ref="#/components/schemas/NotFoundError") + * ), + * @OA\Response( + * response="401", + * description="Unauthorized", + * @OA\JsonContent(ref="#/components/schemas/UnauthorizedError") + * ), + * @OA\Response( + * response="403", + * description="Only user with *deleteCatalog* rights can delete a catalog", + * @OA\JsonContent(ref="#/components/schemas/ForbiddenError") + * ), + * security={ + * {"basicAuth":{}, "bearerAuth":{}, "queryAuth":{}} + * } + * ) + * + * @param array $params + * @param array $body + */ + public function removeCatalog($params) + { + + // Get catalogs and childs + $catalogs = $this->catalogsFunctions->getCatalogs(array( + 'id' => join('/', $params['segments']) + ), true); + + if ( count($catalogs) === 0 ){ + RestoLogUtil::httpError(404); + } + + // If user has not the right to delete catalog then 403 + if ( !$this->user->hasRightsTo(RestoUser::DELETE_CATALOG, array('catalog' => $catalogs[0])) ) { + return RestoLogUtil::httpError(403); + } + + // If catalog has childs it cannot be removed + for ($i = 1, $ii = count($catalogs); $i < $ii; $i--) { + if (isset($params['force']) && filter_var($params['force'], FILTER_VALIDATE_BOOLEAN) === true) { + return RestoLogUtil::httpError(400, 'TODO - force removal of non empty catalog is not implemented'); + } + else { + return RestoLogUtil::httpError(400, 'The catalog cannot be deleted because it has ' . (count($catalogs) - 1) . ' childs'); + } + } + + return RestoLogUtil::success('Catalog deleted', $this->catalogsFunctions->removeCatalog($catalogs[0]['id'])); + + } + + /** * * Return an asset href within an HTTP 301 Redirect message @@ -1294,4 +1583,32 @@ private function getRootCatalogLinks() return $links; } + /** + * Return path identifier from input catlaog + * + * @param array $catalog + * @return string + */ + private function getIdPath($catalog) + { + + $parentId = ''; + + // Retrieve parent if any + for ($i = 0, $ii = count($catalog['links']); $i < $ii; $i++ ) { + if ( isset($catalog['links'][$i]['rel']) &&$catalog['links'][$i]['rel'] === 'parent' ) { + $theoricalUrl = $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS; + $exploded = explode($theoricalUrl, $catalog['links'][$i]['href']); + if (count($exploded) !== 2) { + return RestoLogUtil::httpError(400, 'Parent link is set but it\'s url is invalid - should starts with ' . $theoricalUrl); + } + $parentId = str_starts_with($exploded[1], '/') ? substr($exploded[1], 1) : $exploded[1]; + break; + } + } + + return ($parentId === '' ? '' : $parentId . '/') . $catalog['id']; + + } + } diff --git a/app/resto/core/addons/STACCatalog.php b/app/resto/core/addons/STACCatalog.php deleted file mode 100644 index 6f01fe84..00000000 --- a/app/resto/core/addons/STACCatalog.php +++ /dev/null @@ -1,357 +0,0 @@ -catalogsFunctions = new CatalogsFunctions($this->context->dbDriver); - } - - /** - * Add a catalog - * - * @OA\Post( - * path="/catalogs/*", - * summary="Add a STAC catalog", - * description="Add a STAC catalog", - * tags={"STAC"}, - * @OA\RequestBody( - * description="A valid STAC Catalog", - * required=true, - * @OA\JsonContent(ref="#/components/schemas/Catalog") - * ), - * @OA\Response( - * response="200", - * description="The catalog is created", - * @OA\JsonContent( - * @OA\Property( - * property="status", - * type="string", - * description="Status is *success*" - * ), - * @OA\Property( - * property="message", - * type="string", - * description="Message information" - * ), - * example={ - * "status": "success", - * "message": "Catalog created" - * } - * ) - * ), - * @OA\Response( - * response="400", - * description="Missing one of the mandatory input property", - * @OA\JsonContent(ref="#/components/schemas/BadRequestError") - * ), - * @OA\Response( - * response="401", - * description="Unauthorized", - * @OA\JsonContent(ref="#/components/schemas/UnauthorizedError") - * ), - * @OA\Response( - * response="403", - * description="Only user with *createCatalog* rights can create a catalog", - * @OA\JsonContent(ref="#/components/schemas/ForbiddenError") - * ), - * security={ - * {"basicAuth":{}, "bearerAuth":{}, "queryAuth":{}} - * } - * ) - * - * @param array $params - * @param array $body - */ - public function addCatalog($params, $body) - { - - if (!$this->user->hasRightsTo(RestoUser::CREATE_CATALOG)) { - return RestoLogUtil::httpError(403); - } - - /* - * Check mandatory properties - */ - /*if ( isset($body['stac_version']) ) { - return RestoLogUtil::httpError(400, 'Missing mandatory catalog stac_version - should be set to ' . STAC::STAC_VERSION ); - }*/ - if ( !isset($body['id']) ) { - return RestoLogUtil::httpError(400, 'Missing mandatory catalog id'); - } - if ( !isset($body['description']) ) { - return RestoLogUtil::httpError(400, 'Missing mandatory description'); - } - if ( !isset($body['links']) ) { - $body['links'] = array(); - } - - /* - * Convert input catalog to resto:catalog - * i.e. add rtype and hashtag properties and convert id to a path - */ - $body['rtype'] = 'catalog'; - $body['hashtag'] = 'catalog' . RestoConstants::TAG_SEPARATOR . $body['id']; - $body['id'] = $this->getIdPath($body); - - if ($this->catalogsFunctions->getCatalog($body['id']) !== null) { - RestoLogUtil::httpError(409, 'Catalog ' . $body['id'] . ' already exists'); - } - $baseUrl = $this->context->core['baseUrl']; - return RestoLogUtil::success('Catalog added', $this->catalogsFunctions->storeCatalog($body, $this->user->profile['id'], $baseUrl, null, null)); - - } - - /** - * Update catalog - * - * @OA\Put( - * path="/catalogs/*", - * summary="Update catalog", - * description="Update catalog", - * tags={"STAC"}, - * @OA\Parameter( - * name="catalogId", - * in="path", - * required=true, - * description="Catalog identifier", - * @OA\Schema( - * type="string" - * ) - * ), - * @OA\RequestBody( - * description="Catalog fields to be update limited to title and description", - * required=true, - * @OA\JsonContent(ref="#/components/schemas/Catalog") - * ), - * @OA\Response( - * response="200", - * description="Catalog is updated", - * @OA\JsonContent( - * @OA\Property( - * property="status", - * type="string", - * description="Status is *success*" - * ), - * @OA\Property( - * property="message", - * type="string", - * description="Message information" - * ), - * example={ - * "status": "success", - * "message": "Catalog updated" - * } - * ) - * ), - * @OA\Response( - * response="404", - * description="Not found", - * @OA\JsonContent(ref="#/components/schemas/NotFoundError") - * ), - * @OA\Response( - * response="401", - * description="Unauthorized", - * @OA\JsonContent(ref="#/components/schemas/UnauthorizedError") - * ), - * @OA\Response( - * response="403", - * description="Only user with *updateCatalog* rights can update a catalog", - * @OA\JsonContent(ref="#/components/schemas/ForbiddenError") - * ), - * security={ - * {"basicAuth":{}, "bearerAuth":{}, "queryAuth":{}} - * } - * ) - * - * @param array $params - * @param array $body - */ - public function updateCatalog($params, $body) - { - - // Get catalogs and childs - $catalogs = $this->catalogsFunctions->getCatalogs(array( - 'id' => join('/', $params['segments']) - ), true); - - if ( count($catalogs) === 0 ){ - RestoLogUtil::httpError(404); - } - - - // If user has not the right to update catalog then 403 - if ( !$this->user->hasRightsTo(RestoUser::UPDATE_CATALOG, array('catalog' => $catalogs[0])) ) { - return RestoLogUtil::httpError(403); - } - - $updatable = array('title', 'description', 'owner'); - for ($i = count($updatable); $i--;) { - if ( isset($body[$updatable[$i]]) ) { - $catalogs[0][$updatable[$i]] = $body[$updatable[$i]]; - } - } - - return RestoLogUtil::success('Catalog updated', $this->catalogsFunctions->updateCatalog($catalogs[0])); - } - - /** - * Delete catalog - * - * @OA\Delete( - * path="/catalogs/*", - * summary="Delete catalog", - * description="Delete catalog", - * tags={"STAC"}, - * @OA\Parameter( - * name="catalogId", - * in="path", - * required=true, - * description="Catalog identifier", - * @OA\Schema( - * type="string" - * ) - * ), - * @OA\Parameter( - * name="force", - * in="query", - * description="Force catalog removal even if this catalog has child. In this case, catalogs childs are attached to the remove catalog parent", - * @OA\Schema( - * type="boolean" - * ) - * ), - * @OA\Response( - * response="200", - * description="Catalog deleted", - * @OA\JsonContent( - * @OA\Property( - * property="status", - * type="string", - * description="Status is *success*" - * ), - * @OA\Property( - * property="message", - * type="string", - * description="Message information" - * ), - * example={ - * "status": "success", - * "message": "Catalog deleted", - * "featuresUpdated": 345 - * } - * ) - * ), - * @OA\Response( - * response="404", - * description="Not found", - * @OA\JsonContent(ref="#/components/schemas/NotFoundError") - * ), - * @OA\Response( - * response="401", - * description="Unauthorized", - * @OA\JsonContent(ref="#/components/schemas/UnauthorizedError") - * ), - * @OA\Response( - * response="403", - * description="Only user with *deleteCatalog* rights can delete a catalog", - * @OA\JsonContent(ref="#/components/schemas/ForbiddenError") - * ), - * security={ - * {"basicAuth":{}, "bearerAuth":{}, "queryAuth":{}} - * } - * ) - * - * @param array $params - * @param array $body - */ - public function removeCatalog($params) - { - - // Get catalogs and childs - $catalogs = $this->catalogsFunctions->getCatalogs(array( - 'id' => join('/', $params['segments']) - ), true); - - if ( count($catalogs) === 0 ){ - RestoLogUtil::httpError(404); - } - - // If user has not the right to delete catalog then 403 - if ( !$this->user->hasRightsTo(RestoUser::DELETE_CATALOG, array('catalog' => $catalogs[0])) ) { - return RestoLogUtil::httpError(403); - } - - // If catalog has childs it cannot be removed - for ($i = 1, $ii = count($catalogs); $i < $ii; $i--) { - if (isset($params['force']) && filter_var($params['force'], FILTER_VALIDATE_BOOLEAN) === true) { - return RestoLogUtil::httpError(400, 'TODO - force removal of non empty catalog is not implemented'); - } - else { - return RestoLogUtil::httpError(400, 'The catalog cannot be deleted because it has ' . (count($catalogs) - 1) . ' childs'); - } - } - - return RestoLogUtil::success('Catalog deleted', $this->catalogsFunctions->removeCatalog($catalogs[0]['id'])); - - } - - /** - * Return path identifier from input catlaog - * - * @param array $catalog - * @return string - */ - private function getIdPath($catalog) - { - - $parentId = ''; - - // Retrieve parent if any - for ($i = 0, $ii = count($catalog['links']); $i < $ii; $i++ ) { - if ( isset($catalog['links'][$i]['rel']) &&$catalog['links'][$i]['rel'] === 'parent' ) { - $theoricalUrl = $this->context->core['baseUrl'] . RestoRouter::ROUTE_TO_CATALOGS; - $exploded = explode($theoricalUrl, $catalog['links'][$i]['href']); - if (count($exploded) !== 2) { - return RestoLogUtil::httpError(400, 'Parent link is set but it\'s url is invalid - should starts with ' . $theoricalUrl); - } - $parentId = str_starts_with($exploded[1], '/') ? substr($exploded[1], 1) : $exploded[1]; - break; - } - } - - return ($parentId === '' ? '' : $parentId . '/') . $catalog['id']; - - } - - -} diff --git a/app/resto/core/dbfunctions/CatalogsFunctions.php b/app/resto/core/dbfunctions/CatalogsFunctions.php index d3904ca1..ab972132 100755 --- a/app/resto/core/dbfunctions/CatalogsFunctions.php +++ b/app/resto/core/dbfunctions/CatalogsFunctions.php @@ -341,8 +341,7 @@ public function updateCatalog($catalog) 'catalogsUpdated' => 0 ); } - print_r($set); - print_r($values); + $results = $this->dbDriver->fetch($this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.catalog SET ' . join(',', $set) . ' WHERE public.normalize(id)=public.normalize($1) RETURNING id', $values, 500, 'Cannot update facet ' . $catalog['id'])); return array( From 286507f9e6230a1174e959ae66d5acc938209841 Mon Sep 17 00:00:00 2001 From: jjrom Date: Tue, 11 Jun 2024 18:17:59 +0200 Subject: [PATCH 19/27] Set version to resto 9.0.0-RC1 --- app/resto/core/RestoConstants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/resto/core/RestoConstants.php b/app/resto/core/RestoConstants.php index de415cdd..972efc07 100644 --- a/app/resto/core/RestoConstants.php +++ b/app/resto/core/RestoConstants.php @@ -20,7 +20,7 @@ class RestoConstants // [IMPORTANT] Starting resto 7.x, default routes are defined in RestoRouter class // resto version - const VERSION = '8.0.10'; + const VERSION = '9.0.0-RC1'; /* ============================================================ * NEVER EVER TOUCH THESE VALUES From d5910f07abafa42d86c765f0ccbcc95e1ae7d7ee Mon Sep 17 00:00:00 2001 From: jjrom Date: Wed, 12 Jun 2024 09:40:42 +0200 Subject: [PATCH 20/27] Convert STAC addon to STACAPI class --- app/resto/core/RestoCollection.php | 2 +- app/resto/core/RestoCollections.php | 2 +- app/resto/core/RestoConstants.php | 2 +- app/resto/core/RestoContext.php | 5 ++-- app/resto/core/RestoRouter.php | 22 +++++++------- .../core/{addons/STAC.php => api/STACAPI.php} | 21 +++++++------ app/resto/core/api/ServicesAPI.php | 4 +-- app/resto/core/utils/RestoFeatureUtil.php | 2 +- build/resto/config.php.template | 7 +---- config.env | 8 ++--- docs/api/resto-api.html | 30 +++++++++---------- docs/api/resto-api.json | 10 +++---- 12 files changed, 54 insertions(+), 61 deletions(-) rename app/resto/core/{addons/STAC.php => api/STACAPI.php} (98%) diff --git a/app/resto/core/RestoCollection.php b/app/resto/core/RestoCollection.php index d14165d1..d25aa0b4 100755 --- a/app/resto/core/RestoCollection.php +++ b/app/resto/core/RestoCollection.php @@ -993,7 +993,7 @@ public function toArray() $osDescription = $this->osDescription[$this->context->lang] ?? $this->osDescription['en']; $collectionArray = array( - 'stac_version' => STAC::STAC_VERSION, + 'stac_version' => STACAPI::STAC_VERSION, 'stac_extensions' => $this->model->stacExtensions, 'id' => $this->id, 'type' => 'Collection', diff --git a/app/resto/core/RestoCollections.php b/app/resto/core/RestoCollections.php index 00d96ed4..3fb192e0 100755 --- a/app/resto/core/RestoCollections.php +++ b/app/resto/core/RestoCollections.php @@ -233,7 +233,7 @@ public function load($params = array()) public function toArray() { $collections = array( - 'stac_version' => STAC::STAC_VERSION, + 'stac_version' => STACAPI::STAC_VERSION, 'id' => $this->context->osDescription['ShortName'], 'type' => 'Catalog', 'title' => $this->context->osDescription['LongName'] ?? $this->context->osDescription['ShortName'], diff --git a/app/resto/core/RestoConstants.php b/app/resto/core/RestoConstants.php index 972efc07..988cd4f6 100644 --- a/app/resto/core/RestoConstants.php +++ b/app/resto/core/RestoConstants.php @@ -20,7 +20,7 @@ class RestoConstants // [IMPORTANT] Starting resto 7.x, default routes are defined in RestoRouter class // resto version - const VERSION = '9.0.0-RC1'; + const VERSION = '9.0.0-RC2'; /* ============================================================ * NEVER EVER TOUCH THESE VALUES diff --git a/app/resto/core/RestoContext.php b/app/resto/core/RestoContext.php index 27c0a739..84d766da 100755 --- a/app/resto/core/RestoContext.php +++ b/app/resto/core/RestoContext.php @@ -50,9 +50,8 @@ class RestoContext // True to store all user queries to database 'storeQuery' => false, - // True to display "catalogs" and "facets" childs directly within the STAC root endpoint - // instead of having them under respectively "catalogs" and "facets" parents - 'mergeRootCatalogLinks' => false, + // Display catalog that have at least 'catalogMinMatch' object + 'catalogMinMatch' => 0, // Use cache 'useCache' => false, diff --git a/app/resto/core/RestoRouter.php b/app/resto/core/RestoRouter.php index 1d273c27..4447a36b 100755 --- a/app/resto/core/RestoRouter.php +++ b/app/resto/core/RestoRouter.php @@ -87,7 +87,7 @@ class RestoRouter array('GET', RestoRouter::ROUTE_TO_COLLECTION, false, 'CollectionsAPI::getCollection'), // Get :collectionId description array('PUT', RestoRouter::ROUTE_TO_COLLECTION, true, 'CollectionsAPI::updateCollection'), // Update :collectionId array('DELETE', RestoRouter::ROUTE_TO_COLLECTION, true, 'CollectionsAPI::deleteCollection'), // Delete :collectionId - array('GET', RestoRouter::ROUTE_TO_COLLECTION . '/queryables', false, 'STAC::getQueryables'), // OAFeature API - Queryables + array('GET', RestoRouter::ROUTE_TO_COLLECTION . '/queryables', false, 'STACAPI::getQueryables'), // OAFeature API - Queryables // API for features array('GET', RestoRouter::ROUTE_TO_FEATURES, false, 'FeaturesAPI::getFeaturesInCollection'), // Search features in :collectionId @@ -112,16 +112,16 @@ class RestoRouter array('POST', RestoRouter::ROUTE_TO_RESET_PASSWORD, false, 'ServicesAPI::resetPassword'), // Reset password // STAC - array('GET', RestoRouter::ROUTE_TO_ASSETS . '/{urlInBase64}', false, 'STAC::getAsset'), // Get an asset using HTTP 301 permanent redirect - array('GET', RestoRouter::ROUTE_TO_CATALOGS, false, 'STAC::getCatalogs'), - array('GET', RestoRouter::ROUTE_TO_CATALOGS . '/*', false, 'STAC::getCatalogs'), // Get catalogs - array('GET', RestoRouter::ROUTE_TO_STAC_CHILDREN, false, 'STAC::getChildren'), // STAC API - Children - array('GET', RestoRouter::ROUTE_TO_STAC_QUERYABLES, false, 'STAC::getQueryables'), // STAC/OAFeature API - Queryables - array('GET', RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STAC::search'), // STAC API - core search (GET) - array('POST', RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STAC::search'), // STAC API - core search (POST) - array('POST', RestoRouter::ROUTE_TO_CATALOGS , true , 'STAC::addCatalog'), // STAC - Add a catalog - array('PUT' , RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STAC::updateCatalog'), // STAC - Update a catalog - array('DELETE', RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STAC::removeCatalog') // STAC - Remove a catalog + array('GET', RestoRouter::ROUTE_TO_ASSETS . '/{urlInBase64}', false, 'STACAPI::getAsset'), // Get an asset using HTTP 301 permanent redirect + array('GET', RestoRouter::ROUTE_TO_CATALOGS, false, 'STACAPI::getCatalogs'), + array('GET', RestoRouter::ROUTE_TO_CATALOGS . '/*', false, 'STACAPI::getCatalogs'), // Get catalogs + array('GET', RestoRouter::ROUTE_TO_STAC_CHILDREN, false, 'STACAPI::getChildren'), // STAC API - Children + array('GET', RestoRouter::ROUTE_TO_STAC_QUERYABLES, false, 'STACAPI::getQueryables'), // STAC/OAFeature API - Queryables + array('GET', RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STACAPI::search'), // STAC API - core search (GET) + array('POST', RestoRouter::ROUTE_TO_STAC_SEARCH, false, 'STACAPI::search'), // STAC API - core search (POST) + array('POST', RestoRouter::ROUTE_TO_CATALOGS , true , 'STACAPI::addCatalog'), // STAC - Add a catalog + array('PUT' , RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STACAPI::updateCatalog'), // STAC - Update a catalog + array('DELETE', RestoRouter::ROUTE_TO_CATALOGS . '/*', true , 'STACAPI::removeCatalog') // STAC - Remove a catalog ); /* diff --git a/app/resto/core/addons/STAC.php b/app/resto/core/api/STACAPI.php similarity index 98% rename from app/resto/core/addons/STAC.php rename to app/resto/core/api/STACAPI.php index d6596c24..a719bb79 100644 --- a/app/resto/core/addons/STAC.php +++ b/app/resto/core/api/STACAPI.php @@ -109,7 +109,7 @@ * ) * ) */ -class STAC extends RestoAddOn +class STACAPI { /** * Links @@ -234,6 +234,9 @@ class STAC extends RestoAddOn */ private $catalogsFunctions; + private $context; + private $user; + /** * Constructor * @@ -242,8 +245,8 @@ class STAC extends RestoAddOn */ public function __construct($context, $user) { - parent::__construct($context, $user); - $this->options['minMatch'] = isset($this->options['minMatch']) && is_int($this->options['minMatch']) ? $this->options['minMatch'] : 0; + $this->context = $context; + $this->user = $user; $this->catalogsFunctions = new CatalogsFunctions($this->context->dbDriver); } @@ -273,14 +276,14 @@ public function getCatalogs($params) // This is /catalogs if ( !isset($params['segments']) ) { return array( - 'stac_version' => STAC::STAC_VERSION, + 'stac_version' => STACAPI::STAC_VERSION, 'id' => 'catalogs', 'type' => 'Catalog', 'title' => 'Catalogs', 'description' => 'List of available catalogs', 'links' => array_merge( $this->getBaseLinks(), - $this->getRootCatalogLinks($this->options['minMatch']) + $this->getRootCatalogLinks($this->context->core['catalogMinMatch']) ) ); } @@ -356,7 +359,7 @@ public function addCatalog($params, $body) * Check mandatory properties */ /*if ( isset($body['stac_version']) ) { - return RestoLogUtil::httpError(400, 'Missing mandatory catalog stac_version - should be set to ' . STAC::STAC_VERSION ); + return RestoLogUtil::httpError(400, 'Missing mandatory catalog stac_version - should be set to ' . STACAPI::STAC_VERSION ); }*/ if ( !isset($body['id']) ) { return RestoLogUtil::httpError(400, 'Missing mandatory catalog id'); @@ -671,7 +674,7 @@ public function getChildren($params) // Initialize router to process each children individually $router = new RestoRouter($this->context, $this->user); - $links = $this->getRootCatalogLinks($this->options['minMatch']); + $links = $this->getRootCatalogLinks($this->context->core['catalogMinMatch']); for ($i = 0, $ii = count($links); $i < $ii; $i++) { if ($links[$i]['rel'] == 'child') { try { @@ -1327,7 +1330,7 @@ private function processPath($segments, $params = array()) // The path is the catalog identifier $parentAndChilds = $this->getParentAndChilds(join('/', $segments)); return array( - 'stac_version' => STAC::STAC_VERSION, + 'stac_version' => STACAPI::STAC_VERSION, 'id' => $segments[count($segments) -1 ], 'title' => $parentAndChilds['parent']['title'] ?? '', 'description' => $parentAndChilds['parent']['description'] ?? '', @@ -1563,7 +1566,7 @@ private function getRootCatalogLinks() for ($i = 0, $ii = count($catalogs); $i < $ii; $i++) { // Returns only catalogs with count >= minMath - if ($catalogs[$i]['counters']['total'] >= $this->options['minMatch']) { + if ($catalogs[$i]['counters']['total'] >= $this->context->core['catalogMinMatch']) { $link = array( 'rel' => 'child', 'title' => $catalogs[$i]['title'], diff --git a/app/resto/core/api/ServicesAPI.php b/app/resto/core/api/ServicesAPI.php index 42093d88..b8cada94 100755 --- a/app/resto/core/api/ServicesAPI.php +++ b/app/resto/core/api/ServicesAPI.php @@ -182,7 +182,7 @@ public function hello() { return array( - 'stac_version' => STAC::STAC_VERSION, + 'stac_version' => STACAPI::STAC_VERSION, 'id' => 'root', 'type' => 'Catalog', 'title' => $this->title, @@ -511,6 +511,6 @@ public function resetPassword($params, $body) */ private function conformsTo() { - return STAC::CONFORMANCE_CLASSES; + return STACAPI::CONFORMANCE_CLASSES; } } diff --git a/app/resto/core/utils/RestoFeatureUtil.php b/app/resto/core/utils/RestoFeatureUtil.php index afe7a807..603c5034 100755 --- a/app/resto/core/utils/RestoFeatureUtil.php +++ b/app/resto/core/utils/RestoFeatureUtil.php @@ -142,7 +142,7 @@ private function formatRawFeatureArray($rawFeatureArray, $collection) ) ), 'assets' => array(), - 'stac_version' => STAC::STAC_VERSION, + 'stac_version' => STACAPI::STAC_VERSION, 'stac_extensions' => $collection->model->stacExtensions ); diff --git a/build/resto/config.php.template b/build/resto/config.php.template index 7e77676c..8349cbd4 100755 --- a/build/resto/config.php.template +++ b/build/resto/config.php.template @@ -11,7 +11,7 @@ return array( 'endpoint' => '${STORAGE_PUBLIC_ENDPOINT:-/static}' ), 'storeQuery' => ${STORE_QUERY:-false}, - 'mergeRootCatalogLinks' => ${MERGE_ROOT_CATALOG_LINKS:-false}, + 'catalogMinMatch' => ${CATALOG_MINMATCH:-0}, 'timezone' => '${TIMEZONE:-Europe/Paris}', 'tokenDuration' => ${JWT_DURATION:-8640000}, 'userAutoValidation' => ${USER_AUTOVALIDATION:-true}, @@ -88,11 +88,6 @@ return array( ) ), 'addons' => array( - 'STAC' => array( - 'options' => array( - 'minMatch' => ${ADDON_STAC_MINMATCH:-0} - ) - ), 'Cataloger' => array( 'options' => array( 'iTag' => array( diff --git a/config.env b/config.env index d692b44a..2df8056d 100644 --- a/config.env +++ b/config.env @@ -96,9 +96,8 @@ JWT_PASSPHRASE="Super secret passphrase" ### True to store all user queries to database #STORE_QUERY=false -### True to display "catalogs" and "facets" childs directly within the STAC root endpoint -### instead of having them under respectively "catalogs" and "facets" parents -#MERGE_ROOT_CATALOG_LINKS=false +### STAC - only display child/items links with at least minMatch child/items +#CATALOG_MINMATCH=0 ### Automatic user validation on activation #USER_AUTOVALIDATION=true @@ -173,9 +172,6 @@ ADDON_TAG_ITAG_ENDPOINT=http://itag #ADDON_TAG_ITAG_TAGGERS=political,physical #ADDON_TAG_ADD_SEARCH_FILTERS=false -### STAC - only display child/items links with at least minMatch child/items -#ADDON_STAC_MINMATCH=0 - ### ===================================================================== ### Server configuration (nginx/php-fpm) ### ===================================================================== diff --git a/docs/api/resto-api.html b/docs/api/resto-api.html index aacc0526..3a896157 100644 --- a/docs/api/resto-api.html +++ b/docs/api/resto-api.html @@ -1560,7 +1560,7 @@
  • - STAC::search + STACAPI::search
  • @@ -1653,12 +1653,12 @@
    • - STAC::getAsset + STACAPI::getAsset
    • - STAC::getCatalogs + STACAPI::getCatalogs
    • @@ -1668,12 +1668,12 @@
    • - STAC::getChildren + STACAPI::getChildren
    • - STAC::getQueryables + STACAPI::getQueryables
    • @@ -3754,8 +3754,8 @@

      Responses

      Feature

      A feature is an application object that represents a physical entity e.g. a building, a river, a person, a coverage taken by a a satellite. Practically, a resto feature is defined by a set of metadata including a geographical location (i.e. a (Multi)Point, a (Multi)LineString or a (Multi)Polygon). A feature always belongs to one and only one collection.

      - -

      + +

      Code samples

      @@ -6963,8 +6963,8 @@

      Response Schema

      basicAuth & bearerAuth & queryAuth

      STAC

      -

      STAC::getAsset

      -

      +

      STACAPI::getAsset

      +

      Code samples

      @@ -7024,8 +7024,8 @@

      Responses

      -

      STAC::getCatalogs

      -

      +

      STACAPI::getCatalogs

      +

      Code samples

      @@ -7247,8 +7247,8 @@

      Response Schema

      To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

      STAC::getChildren

      -

      +

      STACAPI::getChildren

      +

      Code samples

      @@ -7641,8 +7641,8 @@

      Enumerated Values

      -

      STAC::getQueryables

      -

      +

      STACAPI::getQueryables

      +

      Code samples

      diff --git a/docs/api/resto-api.json b/docs/api/resto-api.json index cfc21b6b..5775e106 100644 --- a/docs/api/resto-api.json +++ b/docs/api/resto-api.json @@ -22,7 +22,7 @@ ], "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", - "operationId": "STAC::getAsset", + "operationId": "STACAPI::getAsset", "parameters": [ { "name": "urlInBase64", @@ -51,7 +51,7 @@ ], "summary": "Get STAC catalogs", "description": "Get STAC catalogs", - "operationId": "STAC::getCatalogs", + "operationId": "STACAPI::getCatalogs", "responses": { "200": { "description": "STAC catalog definition - contains links to child catalogs and/or items", @@ -158,7 +158,7 @@ ], "summary": "Get root child catalogs", "description": "List of children of this catalog", - "operationId": "STAC::getChildren", + "operationId": "STACAPI::getChildren", "responses": { "200": { "description": "List of children of the root catalog", @@ -189,7 +189,7 @@ ], "summary": "Queryables for STAC API", "description": "Queryable names for the STAC API Item Search filter.", - "operationId": "STAC::getQueryables", + "operationId": "STACAPI::getQueryables", "responses": { "200": { "description": "Queryables for STAC API", @@ -211,7 +211,7 @@ ], "summary": "STAC search endpoint", "description": "List of filters to search features within all collections", - "operationId": "STAC::search", + "operationId": "STACAPI::search", "parameters": [ { "name": "model", From 4586728034539a7f0adf0f7e7e8d6b202891300b Mon Sep 17 00:00:00 2001 From: jjrom Date: Wed, 12 Jun 2024 09:46:51 +0200 Subject: [PATCH 21/27] Remove dead code --- app/resto/core/RestoCollection.php | 2 +- app/resto/core/RestoModel.php | 2 +- app/resto/core/api/FeaturesAPI.php | 1 - .../core/dbfunctions/CollectionsFunctions.php | 2 +- .../core/dbfunctions/FacetsFunctions.php | 509 ------------------ app/resto/core/utils/STACUtil.php | 48 -- config-dev.env | 4 - 7 files changed, 3 insertions(+), 565 deletions(-) delete mode 100755 app/resto/core/dbfunctions/FacetsFunctions.php delete mode 100755 app/resto/core/utils/STACUtil.php diff --git a/app/resto/core/RestoCollection.php b/app/resto/core/RestoCollection.php index d25aa0b4..968d1fa2 100755 --- a/app/resto/core/RestoCollection.php +++ b/app/resto/core/RestoCollection.php @@ -696,7 +696,7 @@ class RestoCollection * * @OA\Schema( * schema="Statistics", - * description="Collection facets statistics", + * description="Collection statistics", * required={"count", "facets"}, * @OA\Property( * property="count", diff --git a/app/resto/core/RestoModel.php b/app/resto/core/RestoModel.php index 975a99cc..4df7e970 100755 --- a/app/resto/core/RestoModel.php +++ b/app/resto/core/RestoModel.php @@ -134,7 +134,7 @@ abstract class RestoModel * ... * ) * 2. 'auto' - * In this case will be computed from facets table + * In this case will be computed from catalog table */ public $searchFilters = array( diff --git a/app/resto/core/api/FeaturesAPI.php b/app/resto/core/api/FeaturesAPI.php index f35e087a..6fe7b52d 100755 --- a/app/resto/core/api/FeaturesAPI.php +++ b/app/resto/core/api/FeaturesAPI.php @@ -917,7 +917,6 @@ public function deleteFeature($params) RestoLogUtil::httpError(403); } - // Result contains boolean for facetsDeleted $result = (new FeaturesFunctions($this->context->dbDriver))->removeFeature($feature); return RestoLogUtil::success('Feature deleted', array( diff --git a/app/resto/core/dbfunctions/CollectionsFunctions.php b/app/resto/core/dbfunctions/CollectionsFunctions.php index 1dee4741..3558ec62 100755 --- a/app/resto/core/dbfunctions/CollectionsFunctions.php +++ b/app/resto/core/dbfunctions/CollectionsFunctions.php @@ -72,7 +72,7 @@ public function getCollectionDescription($id) } /** - * Get description of all collections including facets + * Get description of all collections * * @param array $params * @return array diff --git a/app/resto/core/dbfunctions/FacetsFunctions.php b/app/resto/core/dbfunctions/FacetsFunctions.php deleted file mode 100755 index 4bc5fc68..00000000 --- a/app/resto/core/dbfunctions/FacetsFunctions.php +++ /dev/null @@ -1,509 +0,0 @@ -dbDriver = $dbDriver; - } - - /** - * Format facet for output - * - * @param array $rawFacet - */ - public static function format($rawFacet) - { - return array( - 'id' => $rawFacet['id'], - 'collection' => $rawFacet['collection'] ?? '*', - 'value' => $rawFacet['value'], - 'parentId' => $rawFacet['pid'], - 'created' => $rawFacet['created'], - 'owner' => $rawFacet['owner'] ?? null, - 'count' => (integer) $rawFacet['counter'], - 'description' => $rawFacet['description'] ?? null, - 'isLeaf' => $rawFacet['isleaf'] - ); - } - - /** - * Get facet from id and/or pid and/or collection) - * - * @param array params - */ - public function getFacets($params = array()) - { - - $facets = array(); - - $values = array(); - $where = array(); - $count = 1; - foreach (array_keys($params) as $key ) { - if (in_array($key, array('id', 'pid', 'collection'))) { - $where[] = 'public.normalize(' . $key . ')=public.normalize($' . $count . ')'; - $values[] = $params[$key]; - $count++; - } - } - - $results = $this->dbDriver->pQuery('SELECT id, collection, value, type, pid, to_iso8601(created) as created, owner, description, counter, isleaf FROM ' . $this->dbDriver->targetSchema . '.facet' . ( empty($where) ? '' : ' WHERE ' . join(' AND ', $where)), $values); - while ($result = pg_fetch_assoc($results)) { - $facets[] = FacetsFunctions::format($result); - } - - return $facets; - } - - /** - * Store facet within database (i.e. add 1 to the counter of facet if exist) - * - * !! THIS FUNCTION IS THREAD SAFE !! - * - * Input facet structure : - * array( - * array( - * 'name' => - * 'type' => - * 'id' => - * 'parentId' => - * ), - * ... - * ) - * - * Or - * array( - * 'id', - * - * ) - * - * @param array $facets - * @param string $userid - * @param string $collectionId - */ - public function storeFacets($facets, $userid, $collectionId = '*') - { - // Empty facets - do nothing - if (!isset($facets) || count($facets) === 0) { - return; - } - - foreach (array_values($facets) as $facetElement) { - /* - * Support for direct hashtag (i.e. not an array) - */ - if (!is_array($facetElement)) { - $facetElement = array( - 'id' => $facetElement, - 'value' => $facetElement, - 'type' => 'hashtag', - 'isLeaf' => true - ); - } - - /* - * Thread safe ingestion using upsert - guarantees that counter is correctly incremented during concurrent transactions - * - * [IMPORTANT] UPSERT with check on parentId only if $facetElement['parentId'] is set - */ - $insert = 'INSERT INTO ' . $this->dbDriver->targetSchema . '.facet (id, collection, value, type, pid, owner, description, created, counter, isleaf) SELECT $1,$2,$3,$4,$5,$6,$7,now(),$8,$9'; - $upsert = 'UPDATE ' . $this->dbDriver->targetSchema . '.facet SET counter=' .(isset($facetElement['counter']) ? 'counter' : 'counter+1') . ' WHERE public.normalize(id)=public.normalize($1) AND public.normalize(collection)=public.normalize($2)' . (isset($facetElement['parentId']) ? ' AND public.normalize(pid)=public.normalize($5)' : ''); - $this->dbDriver->pQuery('WITH upsert AS (' . $upsert . ' RETURNING *) ' . $insert . ' WHERE NOT EXISTS (SELECT * FROM upsert)', array( - $facetElement['id'], - $facetElement['collection'] ?? $collectionId, - $facetElement['value'], - $facetElement['type'], - $facetElement['parentId'] ?? 'root', - $facetElement['owner'] ?? $userid, - $facetElement['description'] ?? null, - // If no input counter is specified - set to 1 - isset($facetElement['counter']) ? $facetElement['counter'] : 1, - isset($facetElement['isLeaf']) && $facetElement['isLeaf'] ? 1 : 0 - ), 500, 'Cannot insert facet ' . $facetElement['id']); - } - } - - /** - * Update facet - * - * @param array $facet - * @return integer // number of facets updated - */ - public function updateFacet($facet) - { - - $values = array( - $facet['id'] - ); - - $canBeUpdated = array( - //'collection', - 'value', - //'type', - //'parentId', - 'owner', - 'description', - //'counter', - //'isLeaf' - ); - - $set = array(); - $count = 2; - foreach (array_keys($facet) as $key ) { - if (in_array($key, $canBeUpdated)) { - $set[] = $key . '=$' . $count; - $values[] = $facet[$key]; - $count++; - } - } - - if ( empty($set) ) { - return array( - 'facetsUpdated' => 0 - ); - } - - $results = $this->dbDriver->fetch($this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.facet SET ' . join(',', $set) . ' WHERE public.normalize(id)=public.normalize($1) RETURNING id', $values, 500, 'Cannot update facet ' . $facet['id'])); - - return array( - 'facetsUpdated' => count($results) - ); - - } - - /** - * Remove facet from id - can only works if facet has no child - * - * @param string $facetId - */ - public function removeFacet($facetId) - { - - $results = $this->dbDriver->fetch($this->dbDriver->pQuery('DELETE FROM ' . $this->dbDriver->targetSchema . '.facet WHERE public.normalize(id)=public.normalize($1) RETURNING id', array($facetId), 500, 'Cannot delete facet' . $facetId)); - $facetsDeleted = count($results); - - // Next remove the facet entry from all features - $query = join(' ', array( - 'UPDATE ' . $this->dbDriver->targetSchema . '.feature SET', - 'hashtags=ARRAY_REMOVE(hashtags, $1),normalized_hashtags=ARRAY_REMOVE(normalized_hashtags,public.normalize($1)),', - 'keywords=(SELECT json_agg(e) FROM json_array_elements(keywords) AS e WHERE e->>\'id\' <> $1)', - 'WHERE normalized_hashtags @> public.normalize_array(ARRAY[$1]) RETURNING id' - ) - ); - $results = $this->dbDriver->fetch($this->dbDriver->pQuery($query, array($facetId), 500, 'Cannot update features' . $facetId)); - - return array( - 'facetDeleted' => $facetsDeleted, - 'featuresUpdated' => count($results) - ); - - } - - /** - * Return STAC Summaries from facets elements from a type for a given collection - * - * Returned array of array indexed by collection id - * - * array( - * 'collection1' => array( - * 'type#' => array( - * 'value1' => count1, - * 'value2' => count2, - * 'parent' => array( - * 'value3' => count3, - * ... - * ) - * ... - * ), - * 'type2' => array( - * ... - * ), - * ... - * ), - * 'collection2' => array( - * 'type#' => array( - * 'value1' => count1, - * 'value2' => count2, - * 'parent' => array( - * 'value3' => count3, - * ... - * ) - * ... - * ), - * 'type2' => array( - * ... - * ), - * ... - * ) - * ) - * - * @param array $types - * @param string $collectionId - * - * @return array - */ - public function getSummaries($types, $collectionId) - { - - $summaries = array(); - - /* - * [Hack] Facet with one of these type are unprefixed - */ - $unprefixed = array( - 'hashtag', - // The following types are from resto-addon-sosa - 'observedProperty', - 'foi', - 'sample', - 'sensor' - ); - - $pivots = array(); - - /* - * Build array - */ - $where = array( - 'counter > 0' - ); - - if ( isset($collectionId) ) { - $where[] = 'public.normalize(collection)=public.normalize(\'' . pg_escape_string($this->dbDriver->getConnection(), $collectionId) . '\')'; - } - - if ( !empty($types) ) { - $where[] = 'type IN(\'' . join('\',\'', $types) . '\')'; - } - else { - $where[] = 'type NOT IN (\'' . join('\',\'', FacetsFunctions::TOPONYM_TYPES) . '\')'; - } - - /* - * Retrieve facets stored by collectionId - */ - $results = $this->dbDriver->query('SELECT id,collection,value,type,pid,counter,to_iso8601(created) as created,owner FROM ' . $this->dbDriver->targetSchema . '.facet' . (count($where) > 0 ? ' WHERE ' . join(' AND ', $where): '')); - - while ($result = pg_fetch_assoc($results)) { - - if ( !isset($pivots[$result['collection']]) ) { - $pivots[$result['collection']] = array(); - } - $typeLen = strlen($result['type']); - - // Landcover special case - if (strpos($result['type'], 'landcover:') === 0) { - $type = 'landcover'; - $typeLen = strlen($type); - } - else { - $type = $result['type']; - } - - if (!isset($pivots[$result['collection']][$type])) { - $pivots[$result['collection']][$type] = array(); - } - - $create = true; - $const = in_array($type, $unprefixed) ? $result['id'] : substr($result['id'], $typeLen + 1); - for ($i = count($pivots[$result['collection']][$type]); $i--;) { - if (isset($pivots[$result['collection']][$type][$i]['const'])) { - if ($pivots[$result['collection']][$type][$i]['const'] === $const) { - $pivots[$result['collection']][$type][$i]['count'] += (integer) $result['counter']; - $create = false; - break; - } - } - } - - if ($create) { - $newPivot = array( - 'const' => $const, - 'count' => (integer) $result['counter'] - ); - if ($result['pid'] !== 'root') { - $newPivot['parentId'] = $result['pid']; - } - if ($result['value'] !== $newPivot['const']) { - $newPivot['title'] = $result['value']; - } - $pivots[$result['collection']][$type][] = $newPivot; - } - } - - foreach (array_keys($pivots) as $collectionId) { - if ( !isset($summaries[$collectionId]) ) { - $summaries[$collectionId] = array(); - } - foreach (array_keys($pivots[$collectionId]) as $key) { - if (count($pivots[$collectionId][$key]) === 1) { - $summaries[$collectionId][$key] = array_merge($pivots[$collectionId][$key][0], array('type' => 'string')); - } else { - $summaries[$collectionId][$key] = array( - 'type' => 'string', - 'oneOf' => $pivots[$collectionId][$key] - ); - } - } - } - - return $summaries; - - } - - /** - * Get facets from keywords - * - * @param array $keywords - * @param array $facetCategories - * @param string $collectionId - * @param array $options - */ - public function getFacetsFromKeywords($keywords, $facetCategories, $collectionId, $options = array()) - { - /* - * One facet per keyword - */ - $facets = array(); - for ($i = count($keywords); $i--;) { - $facetCategory = $this->getFacetCategory($facetCategories, $keywords[$i]['type']); - if (isset($facetCategory)) { - /* - * Compute facets if relative coverage is greater than 20 % - * and absolute coverage is greater than 20% - */ - if (isset($keywords[$i]['value']) && $keywords[$i]['value'] < ($options['minRelCov'] ?? $this->minRelCov)) { - if (!isset($keywords[$i]['gcover']) || $keywords[$i]['gcover'] < ($options['minAbsCov'] ?? $this->minAbsCov)) { - continue; - } - } - - $facets[] = array( - 'id' => $keywords[$i]['id'], - 'parentId' => $keywords[$i]['parentId'] ?? 'root', - 'value' => $keywords[$i]['name'] ?? null, - 'type' => $keywords[$i]['type'], - // [IMPORTANT] catalog facet are always attached to all collections - 'collection' => $keywords[$i]['type'] === 'catalog' ? '*' : $collectionId, - 'isLeaf' => $facetCategory['isLeaf'] - ); - } - } - - return $facets; - } - - /** - * Return an array of hashtags from an array of facets - */ - public function getHashtagsFromFacets($facets) - { - $hashtags = array(); - for ($i = count($facets); $i--;) { - $hashtags[] = is_array($facets[$i]) ? $facets[$i]['id'] : $facets[$i]; - } - return $hashtags; - } - - /** - * Remove feature facets from database - * - * @param array $hashtags - * @param string $collectionId - */ - public function removeFacetsFromHashtags($hashtags, $collectionId) - { - for ($i = count($hashtags); $i--;) { - $this->dbDriver->pQuery('UPDATE ' . $this->dbDriver->targetSchema . '.facet SET counter = GREATEST(0, counter - 1) WHERE public.normalize(id)=public.normalize($1) AND (public.normalize(collection)=public.normalize($2) OR public.normalize(collection)=\'*\')', array($hashtags[$i], $collectionId), 500, 'Cannot delete facet for ' . $collectionId); - } - } - - /** - * Return facet category - * - * @param array $facetCategories - * @param string $type - */ - private function getFacetCategory($facetCategories, $type) - { - if (! isset($type)) { - return null; - } - for ($i = count($facetCategories); $i--;) { - $categoryLength = count($facetCategories[$i]); - for ($j = $categoryLength; $j--;) { - if ($facetCategories[$i][$j] === $type) { - return array( - 'category' => $facetCategories[$i], - 'isLeaf' => $j == $categoryLength - 1 - ); - } - } - } - - /* - * Otherwise return $type as a new facet category - */ - return array( - 'category' => $type, - 'isLeaf' => true - ); - } - -} diff --git a/app/resto/core/utils/STACUtil.php b/app/resto/core/utils/STACUtil.php deleted file mode 100755 index 9097383b..00000000 --- a/app/resto/core/utils/STACUtil.php +++ /dev/null @@ -1,48 +0,0 @@ -context = $context; - $this->user =$user; - } - - - -} \ No newline at end of file diff --git a/config-dev.env b/config-dev.env index 23b17f5c..26defbdb 100644 --- a/config-dev.env +++ b/config-dev.env @@ -92,10 +92,6 @@ JWT_PASSPHRASE="Super secret passphrase" ### True to store all user queries to database #STORE_QUERY=false -### True to display "catalogs" and "facets" childs directly within the STAC root endpoint -### instead of having them under respectively "catalogs" and "facets" parents -#MERGE_ROOT_CATALOG_LINKS=false - ### Automatic user validation on activation #USER_AUTOVALIDATION=true From 0fdab1dddaab919ad9107f26e8db714d98a5b64e Mon Sep 17 00:00:00 2001 From: jjrom Date: Wed, 12 Jun 2024 10:02:21 +0200 Subject: [PATCH 22/27] Align documentation with new API --- app/resto/core/RestoCollection.php | 75 +- app/resto/core/addons/Cataloger.php | 6 +- app/resto/core/api/CollectionsAPI.php | 21 - docs/api/resto-api.html | 4579 ++++++++++++------------- docs/api/resto-api.json | 4512 ++++++++++++------------ 5 files changed, 4482 insertions(+), 4711 deletions(-) diff --git a/app/resto/core/RestoCollection.php b/app/resto/core/RestoCollection.php index 968d1fa2..555a777a 100755 --- a/app/resto/core/RestoCollection.php +++ b/app/resto/core/RestoCollection.php @@ -692,80 +692,7 @@ class RestoCollection public $osDescription = null; /** - * Statistics - * - * @OA\Schema( - * schema="Statistics", - * description="Collection statistics", - * required={"count", "facets"}, - * @OA\Property( - * property="count", - * type="integer", - * description="Total number of features in the collection" - * ), - * @OA\Property( - * property="facets", - * description="Statistics per facets", - * @OA\JsonContent( - * @OA\Property( - * property="continent", - * type="array", - * description="Number of features in the collection per continent" - * ), - * @OA\Property( - * property="instrument", - * type="array", - * description="Number of features in the collection per instrument" - * ), - * @OA\Property( - * property="platform", - * type="array", - * description="Number of features in the collection per platform" - * ), - * @OA\Property( - * property="processingLevel", - * type="array", - * description="Number of features in the collection per processing level" - * ), - * @OA\Property( - * property="productType", - * type="array", - * description="Number of features in the collection per product Type" - * ) - * ) - * ), - * example={ - * "count": 5322724, - * "facets": { - * "continent": { - * "Africa": 671538, - * "Antarctica": 106337, - * "Asia": 747847, - * "Europe": 1992756, - * "North America": 1012027, - * "Oceania": 218789, - * "Seven seas (open ocean)": 9481, - * "South America": 313983 - * }, - * "instrument": { - * "HRS": 2, - * "MSI": 5322722 - * }, - * "platform": { - * "S2A": 3346319, - * "S2B": 1976403, - * "SPOT6": 1 - * }, - * "processingLevel": { - * "LEVEL1C": 5322722 - * }, - * "productType": { - * "PX": 2, - * "REFLECTANCE": 5322722 - * } - * } - * } - * ) + * Summaries */ private $summaries = null; diff --git a/app/resto/core/addons/Cataloger.php b/app/resto/core/addons/Cataloger.php index 0a48f851..53d3676c 100644 --- a/app/resto/core/addons/Cataloger.php +++ b/app/resto/core/addons/Cataloger.php @@ -420,7 +420,7 @@ private function catalogsFromProperties($properties, $collection) */ $model = isset($collection) ? $collection->model : new DefaultModel(); foreach (array_values($model->facetCategories) as $facetCategory) { - $catalogs = array_merge($catalogs, $this->catalogsFromFacets($properties, $facetCategory, $model)); + $catalogs = array_merge($catalogs, $this->catalogsFromFacetCategory($properties, $facetCategory, $model)); } /* @@ -438,14 +438,14 @@ private function catalogsFromProperties($properties, $collection) } /** - * Process catalogs for facets + * Process catalogs from facet category * * @param array $properties * @param array $facetCategory * @param RestoModel $model * @return array */ - private function catalogsFromFacets($properties, $facetCategory, $model) + private function catalogsFromFacetCategory($properties, $facetCategory, $model) { $parentId = null; $catalogs = array(); diff --git a/app/resto/core/api/CollectionsAPI.php b/app/resto/core/api/CollectionsAPI.php index 11bca50a..415f53e4 100755 --- a/app/resto/core/api/CollectionsAPI.php +++ b/app/resto/core/api/CollectionsAPI.php @@ -72,17 +72,6 @@ public function __construct($context, $user) * ) * ), * @OA\Property( - * property="summaries", - * type="object", - * @OA\JsonContent( - * @OA\Property( - * property="resto:stats", - * type="object", - * ref="#/components/schemas/Statistics" - * ) - * ) - * ), - * @OA\Property( * property="collections", * description="List of available collections", * type="array", @@ -113,16 +102,6 @@ public function __construct($context, $user) * "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" * } * }, - * "summaries": { - * "resto:stats": { - * "count": 11310, - * "facets": { - * "collection": { - * "L8": 11307 - * } - * } - * } - * }, * "resto:info": { * "osDescription": { * "ShortName": "resto", diff --git a/docs/api/resto-api.html b/docs/api/resto-api.html index 3a896157..61b4f123 100644 --- a/docs/api/resto-api.html +++ b/docs/api/resto-api.html @@ -1496,7 +1496,7 @@
      • - Welcome to resto v8.0.10 + Welcome to resto v9.0.0-RC2
      • @@ -1559,11 +1559,6 @@ @@ -1648,42 +1648,56 @@
      • - STAC + Authentication + +
      • + +
      • + Group + +
          +
        • - STACCatalog::addCatalog + GroupAPI::getGroups
        • - STACAPI::getChildren + GroupAPI::createGroup
        • - STACAPI::getQueryables + GroupAPI::getGroup
        • - STACCatalog::updateCatalog + GroupAPI::deleteGroup
        • - STACCatalog::removeCatalog + GroupAPI::addUser
        • @@ -1692,22 +1706,27 @@
        • - Authentication + Rights
          • - AuthAPI::getToken + RightsAPI::getUserRights
          • - AuthAPI::createToken + RightsAPI::setUserRights
          • - AuthAPI::revokeToken + RightsAPI::getGroupRights + +
          • + +
          • + RightsAPI::setGroupRights
          • @@ -1716,61 +1735,42 @@
          • - Group + STAC - -
          • - -
          • - Rights - -
              -
            • - RightsAPI::getUserRights + STACAPI::removeCatalog
            • - RightsAPI::setUserRights + STACAPI::getAsset
            • - RightsAPI::getGroupRights + STACAPI::getChildren
            • - RightsAPI::setGroupRights + STACAPI::getQueryables
            • @@ -1836,11 +1836,6 @@ -
            • - Statistics - -
            • -
            • Provider @@ -1877,32 +1872,32 @@
            • - Catalog + OutputGroup
            • - Queryables + Rights
            • - Link + Catalog
            • - Asset + Queryables
            • - OutputGroup + Link
            • - Rights + Asset
            • @@ -1956,7 +1951,7 @@
              -

              Welcome to resto v8.0.10

              +

              Welcome to resto v9.0.0-RC2

              Scroll down for code samples, example requests and responses. Select a language for code samples from the tabs above or the mobile navigation menu.

              @@ -2047,16 +2042,6 @@

              Parameters

              "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" } }, - "summaries": { - "resto:stats": { - "count": 11310, - "facets": { - "collection": { - "L8": 11307 - } - } - } - }, "resto:info": { "osDescription": { "ShortName": "resto", @@ -2226,13 +2211,6 @@

              Response Schema

              resto additional information -» summaries -object -false -none -Return collections descriptions - - » collections [OutputCollection] false @@ -3754,20 +3732,20 @@

              Responses

              Feature

              A feature is an application object that represents a physical entity e.g. a building, a river, a person, a coverage taken by a a satellite. Practically, a resto feature is defined by a set of metadata including a geographical location (i.e. a (Multi)Point, a (Multi)LineString or a (Multi)Polygon). A feature always belongs to one and only one collection.

              - -

              +

              FeaturesAPI::getFeaturesInCollection

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/search \
              +curl -X GET http://127.0.0.1:5252/collections/{collectionId}/items \
                 -H 'Accept: application/json'
               
               
              -

              GET /search

              -

              STAC search endpoint

              -

              List of filters to search features within all collections

              -

              Parameters

              +

              GET /collections/{collectionId}/items

              +

              Get features (search on a specific collection)

              +

              List of filters to search features within collection {collectionId}

              +

              Parameters

              @@ -3780,25 +3758,11 @@

              Parameters

              - - - - - - - - - - - - - - - - + + - - + + @@ -3896,7 +3860,7 @@

              Parameters

              - + @@ -3906,11 +3870,11 @@

              Parameters

              - + - + @@ -4074,13 +4038,6 @@

              Parameters

              - - - - - - - @@ -4099,11 +4056,6 @@

              Detailed descriptions

            • #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])
            • -

              fields: 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
              • -

              Example responses

              @@ -4238,7 +4190,7 @@

              Detailed descriptions

              "id": "20ac2fc6-daee-5621-bca4-d88c0bb19da1" } -

              Responses

              +

              Responses

              modelquerystringfalseSearch features within collections belonging to model - e.g. model=SatelliteModel will search in all satellite collections
              collectionsquerystringfalseSearch features within collections - comma separated list of collection identifiers
              ckquerycollectionIdpath stringfalseStands for collection keyword - limit results to collection containing the input keywordtrueCollection identifier
              q query string(date-time) falseBeginning of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:start}Beginning of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:start}.
              end End of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:end}
              createdpublished query string(date-time) falseReturns products with metadata creation date greater or equal than created - OpenSearch {dc:date}Returns products with metadata publication date greater or equal than published - OpenSearch {dc:date}
              prev [MODEL][LandCoverModel] Cultivated area expressed in percent
              fieldsquerystringfalseComma separated list of property fields to be returned. The following reserved keywords can also be used:
              _heatmapNoGeo query boolean
              @@ -4272,20 +4224,21 @@

              Responses

              -

              FeaturesAPI::getFeaturesInCollection

              -

              +

              FeaturesAPI::getFeature

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/collections/{collectionId}/items \
              -  -H 'Accept: application/json'
              +curl -X GET http://127.0.0.1:5252/collections/{collectionId}/items/{featureId} \
              +  -H 'Accept: application/json' \
              +  -H 'Authorization: Bearer {access-token}'
               
               
              -

              GET /collections/{collectionId}/items

              -

              Get features (search on a specific collection)

              -

              List of filters to search features within collection {collectionId}

              -

              Parameters

              +

              GET /collections/{collectionId}/items/{featureId}

              +

              Get feature

              +

              Returns feature {featureId} metadata

              +

              Parameters

              @@ -4305,511 +4258,18 @@

              Parameters

              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
              Collection identifier
              qquerystringfalseFree text search - OpenSearch {searchTerms}. Can include hashtags i.e. text starting with # characters. In this case, use the following:
              limitqueryintegerfalseNumber of results returned per page - between 1 and 500 (default 20) - OpenSearch {count}
              startIndexqueryintegerfalseFirst result to provide - minimum 1, (default 1) - OpenSearch {startIndex}
              pagequeryintegerfalseFirst page to provide - minimum 1, (default 1) - OpenSearch {startPage}
              langquerystringfalseTwo letters language code according to ISO 639-1 (default en) - OpenSearch {language}
              idsquerystringfalseArray of item ids to return. All other filter parameters that further restrict the number of search results (except next and limit) are ignored
              intersectsqueryfeatureIdpath stringfalseRegion of Interest defined in GeoJSON or in Well Known Text standard (WKT) with coordinates in decimal degrees (EPSG:4326) - OpenSearch {geo:geometry}
              bboxqueryarray[number]falseRegion of Interest defined by 'west, south, east, north' coordinates of longitude, latitude, in decimal degrees (EPSG:4326) - OpenSearch {geo:box}trueFeature identifier
              namefields query string false[EXTENSION][egg] Location string e.g. Paris, France or toponym identifier (i.e. geouid:xxxx) - OpenSearch {geo:name}
              lonquerynumberfalseLongitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lat - OpenSearch {geo:lon}
              latquerynumberfalseLatitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lon - OpenSearch {geo:lat}
              radiusquerynumberfalseRadius expressed in meters - should be used with geo:lon and geo:lat - OpenSearch {geo:radius}
              datetimequerystring(date-time)falseSingle date+time, or a range ('/' separator) of the search query. Format should follow RFC-3339 - OpenSearch {time:start}/{time:end}
              startquerystring(date-time)falseBeginning of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:start}.
              endquerystring(date-time)falseEnd of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:end}
              publishedquerystring(date-time)falseReturns products with metadata publication date greater or equal than published - OpenSearch {dc:date}
              prevqueryintegerfalseReturns 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
              nextqueryintegerfalseReturns 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
              pidquerystringfalseLike on product identifier
              sortquerystringfalseSort results by property startDate or created (default startDate). Sorting order is DESCENDING (ASCENDING if property is prefixed by minus sign)
              ownerqueryintegerfalseLimit search to owner's features (i.e. resto user identifier as bigint)
              likesquerystringfalse[EXTENSION][social] Limit search to number of likes (interval)
              likedquerybooleanfalse[EXTENSION][social] Return only liked features from calling user
              statusquerystringfalseFeature status (unusued)
              productTypequerystringfalse[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}
              processingLevelquerystringfalse[MODEL][SatelliteModel] A string identifying the processing level applied to the entry - OpenSearch {eo:processingLevel}
              platformquerystringfalse[MODEL][SatelliteModel] A string with the platform short name (e.g. Sentinel-1) - OpenSearch {eo:platform}
              instrumentquerystringfalse[MODEL][SatelliteModel] A string identifying the instrument (e.g. MERIS, AATSR, ASAR, HRVIR. SAR) - OpenSearch {eo:instrument}
              sensorTypequerystringfalse[MODEL][SatelliteModel] A string identifying the sensor type. Suggested values are: OPTICAL, RADAR, ALTIMETRIC, ATMOSPHERIC, LIMB - OpenSearch {eo:sensorType}
              cloudCoverquerystringfalse[MODEL][OpticalModel] Cloud cover expressed in percent
              snowCoverquerystringfalse[MODEL][OpticalModel] Snow cover expressed in percent
              waterCoverquerystringfalse[MODEL][LandCoverModel] Water area expressed in percent
              urbanCoverquerystringfalse[MODEL][LandCoverModel] Urban area expressed in percent
              iceCoverquerystringfalse[MODEL][LandCoverModel] Ice area expressed in percent
              herbaceousCoverquerystringfalse[MODEL][LandCoverModel] Herbaceous area expressed in percent
              forestCoverquerystringfalse[MODEL][LandCoverModel] Forest area expressed in percent
              floodedCoverquerystringfalse[MODEL][LandCoverModel] Flooded area expressed in percent
              desertCoverquerystringfalse[MODEL][LandCoverModel] Desert area expressed in percent
              cultivatedCoverquerystringfalse[MODEL][LandCoverModel] Cultivated area expressed in percent
              _heatmapNoGeoquerybooleanfalse[EXTENSION][Heatmap] True to compute search result heatmap without taking account geographical filter
              -

              Detailed descriptions

              -

              q: 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])
              • -
              -
              -

              Example responses

              -
              -
              -

              200 Response

              -
              -
              {
              -  "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"
              -}
              -
              -

              Responses

              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
              StatusMeaningDescriptionSchema
              200OKFeatures collectionRestoFeatureCollection
              400Bad RequestBad request (i.e. invalid parameter)BadRequestError
              404Not FoundCollection not FoundNotFoundError
              - -

              FeaturesAPI::getFeature

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/collections/{collectionId}/items/{featureId} \
              -  -H 'Accept: application/json' \
              -  -H 'Authorization: Bearer {access-token}'
              -
              -
              -

              GET /collections/{collectionId}/items/{featureId}

              -

              Get feature

              -

              Returns feature {featureId} metadata

              -

              Parameters

              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
              NameInTypeRequiredDescription
              collectionIdpathstringtrueCollection identifier
              featureIdpathstringtrueFeature identifier
              fieldsquerystringfalseComma separated list of property fields to be returned. The following reserved keywords can also be used:Comma separated list of property fields to be returned. The following reserved keywords can also be used:
              @@ -5198,7 +4658,204 @@

              Parameters

              {
                 "status": "success",
              -  "message": "Feature 7e5caa78-5127-53e5-97ff-ddf44984ef56 deleted"
              +  "message": "Feature 7e5caa78-5127-53e5-97ff-ddf44984ef56 deleted"
              +}
              +
              +
              +

              400 Response

              +
              +
              {
              +  "ErrorCode": 400,
              +  "ErrorMessage": "Bad request"
              +}
              +
              +

              Responses

              + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
              StatusMeaningDescriptionSchema
              200OKThe feature is deleteInline
              400Bad RequestMissing mandatory feature identifierBadRequestError
              401UnauthorizedUnauthorizedUnauthorizedError
              403ForbiddenOnly user with update rights can delete a featureForbiddenError
              404Not FoundFeature not foundNotFoundError
              +

              Response Schema

              +

              Status Code 200

              + + + + + + + + + + + + + + + + + + + + + + + + + + +
              NameTypeRequiredRestrictionsDescription
              » statusstringfalsenoneStatus is success
              » messagestringfalsenoneMessage information
              + +

              FeaturesAPI::updateFeatureProperty

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X PUT http://127.0.0.1:5252/collections/{collectionId}/items/{featureId}/properties/{property} \
              +  -H 'Content-Type: application/json' \
              +  -H 'Accept: application/json' \
              +  -H 'Authorization: Bearer {access-token}'
              +
              +
              +

              PUT /collections/{collectionId}/items/{featureId}/properties/{property}

              +

              Update feature property

              +

              Update {property} for feature {featureId}

              +
              +

              Body parameter

              +
              +
              {
              +  "value": null
              +}
              +
              +

              Parameters

              + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
              NameInTypeRequiredDescription
              collectionIdpathstringtrueCollection identifier
              featureIdpathstringtrueFeature identifier
              propertypathstringtrueProperty to update
              bodybodyobjectfalseProperty value to update
              » valuebodyanyfalseNew property value
              +

              Enumerated Values

              + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
              ParameterValue
              propertytitle
              propertydescription
              propertyvisibility
              propertyowner
              propertystatus
              +
              +

              Example responses

              +
              +
              +

              200 Response

              +
              +
              {
              +  "status": "success",
              +  "message": "Update property for feature b9eeaf6b-9868-5418-9455-3e77cd349e21"
               }
               
              @@ -5209,7 +4866,7 @@

              Parameters

              "ErrorMessage": "Bad request" } -

              Responses

              +

              Responses

              @@ -5223,13 +4880,13 @@

              Responses

              - + - + @@ -5241,7 +4898,7 @@

              Responses

              - + @@ -5252,7 +4909,7 @@

              Responses

              200 OKThe feature is deleteThe property is updated Inline
              400 Bad RequestMissing mandatory feature identifierInvalid property BadRequestError
              403 ForbiddenOnly user with update rights can delete a featureForbidden ForbiddenError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -5285,108 +4942,356 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              FeaturesAPI::updateFeatureProperty

              -

              + +

              Code samples

              # You can also use wget
              -curl -X PUT http://127.0.0.1:5252/collections/{collectionId}/items/{featureId}/properties/{property} \
              -  -H 'Content-Type: application/json' \
              -  -H 'Accept: application/json' \
              -  -H 'Authorization: Bearer {access-token}'
              +curl -X GET http://127.0.0.1:5252/search \
              +  -H 'Accept: application/json'
               
               
              -

              PUT /collections/{collectionId}/items/{featureId}/properties/{property}

              -

              Update feature property

              -

              Update {property} for feature {featureId}

              -
              -

              Body parameter

              -
              -
              {
              -  "value": null
              -}
              -
              -

              Parameters

              +

              GET /search

              +

              STAC search endpoint

              +

              List of filters to search features within all collections

              +

              Parameters

              - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + - + - - - + + + - + - -
              NameInTypeRequiredDescriptionNameInTypeRequiredDescription
              modelquerystringfalseSearch features within collections belonging to model - e.g. model=SatelliteModel will search in all satellite collections
              collectionsquerystringfalseSearch features within collections - comma separated list of collection identifiers
              ckquerystringfalseStands for collection keyword - limit results to collection containing the input keyword
              qquerystringfalseFree text search - OpenSearch {searchTerms}. Can include hashtags i.e. text starting with # characters. In this case, use the following:
              limitqueryintegerfalseNumber of results returned per page - between 1 and 500 (default 20) - OpenSearch {count}
              startIndexqueryintegerfalseFirst result to provide - minimum 1, (default 1) - OpenSearch {startIndex}
              pagequeryintegerfalseFirst page to provide - minimum 1, (default 1) - OpenSearch {startPage}
              langquerystringfalseTwo letters language code according to ISO 639-1 (default en) - OpenSearch {language}
              idsquerystringfalseArray of item ids to return. All other filter parameters that further restrict the number of search results (except next and limit) are ignored
              intersectsquerystringfalseRegion of Interest defined in GeoJSON or in Well Known Text standard (WKT) with coordinates in decimal degrees (EPSG:4326) - OpenSearch {geo:geometry}
              bboxqueryarray[number]falseRegion of Interest defined by 'west, south, east, north' coordinates of longitude, latitude, in decimal degrees (EPSG:4326) - OpenSearch {geo:box}
              namequerystringfalse[EXTENSION][egg] Location string e.g. Paris, France or toponym identifier (i.e. geouid:xxxx) - OpenSearch {geo:name}
              lonquerynumberfalseLongitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lat - OpenSearch {geo:lon}
              latquerynumberfalseLatitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lon - OpenSearch {geo:lat}
              radiusquerynumberfalseRadius expressed in meters - should be used with geo:lon and geo:lat - OpenSearch {geo:radius}
              datetimequerystring(date-time)falseSingle date+time, or a range ('/' separator) of the search query. Format should follow RFC-3339 - OpenSearch {time:start}/{time:end}
              startquerystring(date-time)falseBeginning of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:start}
              endquerystring(date-time)falseEnd of the time slice of the search query. Format should follow RFC-3339 - OpenSearch {time:end}
              createdquerystring(date-time)falseReturns products with metadata creation date greater or equal than created - OpenSearch {dc:date}
              prevqueryintegerfalseReturns 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
              nextqueryintegerfalseReturns 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
              pidquerystringfalseLike on product identifier
              sortquerystringfalseSort results by property startDate or created (default startDate). Sorting order is DESCENDING (ASCENDING if property is prefixed by minus sign)
              ownerqueryintegerfalseLimit search to owner's features (i.e. resto user identifier as bigint)
              likesquerystringfalse[EXTENSION][social] Limit search to number of likes (interval)
              likedquerybooleanfalse[EXTENSION][social] Return only liked features from calling user
              statusquerystringfalseFeature status (unusued)
              productTypequerystringfalse[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}
              processingLevelquerystringfalse[MODEL][SatelliteModel] A string identifying the processing level applied to the entry - OpenSearch {eo:processingLevel}
              platformquerystringfalse[MODEL][SatelliteModel] A string with the platform short name (e.g. Sentinel-1) - OpenSearch {eo:platform}
              instrumentquerystringfalse[MODEL][SatelliteModel] A string identifying the instrument (e.g. MERIS, AATSR, ASAR, HRVIR. SAR) - OpenSearch {eo:instrument}
              collectionIdpathsensorTypequery stringtrueCollection identifierfalse[MODEL][SatelliteModel] A string identifying the sensor type. Suggested values are: OPTICAL, RADAR, ALTIMETRIC, ATMOSPHERIC, LIMB - OpenSearch {eo:sensorType}
              featureIdpathcloudCoverquery stringtrueFeature identifierfalse[MODEL][OpticalModel] Cloud cover expressed in percent
              propertypathsnowCoverquery stringtrueProperty to updatefalse[MODEL][OpticalModel] Snow cover expressed in percent
              bodybodyobjectwaterCoverquerystring falseProperty value to update[MODEL][LandCoverModel] Water area expressed in percent
              » valuebodyanyurbanCoverquerystring falseNew property value[MODEL][LandCoverModel] Urban area expressed in percent
              -

              Enumerated Values

              - - - - + + + + + - - - - + + + + + - - + + + + + - - + + + + + - - + + + + + - - + + + + + + + + + + + + + + + + + + +
              ParameterValueiceCoverquerystringfalse[MODEL][LandCoverModel] Ice area expressed in percent
              propertytitleherbaceousCoverquerystringfalse[MODEL][LandCoverModel] Herbaceous area expressed in percent
              propertydescriptionforestCoverquerystringfalse[MODEL][LandCoverModel] Forest area expressed in percent
              propertyvisibilityfloodedCoverquerystringfalse[MODEL][LandCoverModel] Flooded area expressed in percent
              propertyownerdesertCoverquerystringfalse[MODEL][LandCoverModel] Desert area expressed in percent
              propertystatuscultivatedCoverquerystringfalse[MODEL][LandCoverModel] Cultivated area expressed in percent
              fieldsquerystringfalseComma separated list of property fields to be returned. The following reserved keywords can also be used:
              _heatmapNoGeoquerybooleanfalse[EXTENSION][Heatmap] True to compute search result heatmap without taking account geographical filter
              +

              Detailed descriptions

              +

              q: 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])
              • +
              +

              fields: 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
              • +

              Example responses

              @@ -5394,19 +5299,134 @@

              Enumerated Values

              200 Response

              {
              -  "status": "success",
              -  "message": "Update property for feature b9eeaf6b-9868-5418-9455-3e77cd349e21"
              -}
              -
              -
              -

              400 Response

              -
              -
              {
              -  "ErrorCode": 400,
              -  "ErrorMessage": "Bad request"
              +  "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"
               }
               
              -

              Responses

              +

              Responses

              @@ -5420,67 +5440,25 @@

              Responses

              - - + + - + - - - - - - - - - - - - - +
              200 OKThe property is updatedInlineFeatures collectionRestoFeatureCollection
              400 Bad RequestInvalid propertyBad request (i.e. invalid parameter) BadRequestError
              401UnauthorizedUnauthorizedUnauthorizedError
              403ForbiddenForbiddenForbiddenError
              404 Not FoundFeature not foundCollection not Found NotFoundError
              -

              Response Schema

              -

              Status Code 200

              - - - - - - - - - - - - - - - - - - - - - - - - - - -
              NameTypeRequiredRestrictionsDescription
              » statusstringfalsenoneStatus is success
              » messagestringfalsenoneMessage information
              - -

              STAC

              -

              STACAPI::getAsset

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/assets/{urlInBase64}
              -
              -
              -

              GET /assets/{urlInBase64}

              -

              Download asset

              -

              Return the asset href within an HTTP 301 Redirect message. This allows to keep track of download of external assets in resto statistics

              -

              Parameters

              - - - - - - - - - - - - - - - - - - - -
              NameInTypeRequiredDescription
              urlInBase64pathstringtrueAsset url encoded in Base64
              -

              Responses

              - - - - - - - - - - - - - - - - - - - - - - - -
              StatusMeaningDescriptionSchema
              301Moved PermanentlyHTTP/1.1 301 Moved PermanentlyNone
              400Bad RequestInvalid base64 encoded urlNone
              - -

              STACAPI::getCatalogs

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/catalogs/* \
              -  -H 'Accept: application/json'
              -
              -
              -

              GET /catalogs/*

              -

              Get STAC catalogs

              -

              Get STAC catalogs

              -
              -

              Example responses

              -
              -
              -

              200 Response

              -
              -
              {
              -  "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"
              -}
              -
              -

              Responses

              - - - - - - - - - - - - - - - - - - - - - - - -
              StatusMeaningDescriptionSchema
              200OKSTAC catalog definition - contains links to child catalogs and/or itemsCatalog
              404Not FoundNot foundNone
              - -

              STACCatalog::addCatalog

              -

              +

              Authentication

              +

              AuthAPI::getToken

              +

              Code samples

              # You can also use wget
              -curl -X POST http://127.0.0.1:5252/catalogs/* \
              -  -H 'Content-Type: application/json' \
              +curl -X GET http://127.0.0.1:5252/auth \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              POST /catalogs/*

              -

              Add a STAC catalog

              -

              Add a STAC catalog as a facet entry

              -
              -

              Body parameter

              -
              -
              {
              -  "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"
              -}
              -
              -

              Parameters

              - - - - - - - - - - - - - - - - - - - -
              NameInTypeRequiredDescription
              bodybodyCatalogtrueA valid STAC Catalog
              +

              GET /auth

              +

              Get an authentication token

              +

              Get a fresh authentication token (aka rJWT).

              Example responses

              200 Response

              -
              {
              -  "status": "success",
              -  "message": "Catalog created"
              +
              {
              +  "token": "eyJzdWIiOiIxOTQ2NTIwMjk3MjEzNTI3MDUyIiwiaWF0IjoxNTQ2MjY2NTU3LCJleHAiOjE1NDYyNzAxNTd9.nI4q0LBqGOG0a6GCjxWvUiVA6hKndN9mJrjuT1WG1Xo",
              +  "profile": {
              +    "id": "1356771884787565573",
              +    "picture": "https://robohash.org/d0e907f8b6f4ee74cd4c38a515e2a4de?gravatar=hashed&bgset=any&size=400x400",
              +    "groups": [
              +      "1"
              +    ],
              +    "name": "jrom",
              +    "followers": 185,
              +    "followings": 144,
              +    "firstname": "Jérôme",
              +    "lastname": "Gasperi",
              +    "bio": "Working on new features for the next major release of SnapPlanet",
              +    "registrationdate": "2016-10-08T22:50:34.187217Z",
              +    "topics": "earth,fires,geology,glaciology,volcanism",
              +    "followed": false,
              +    "followme": false
              +  }
               }
               
              -

              400 Response

              +

              401 Response

              {
              -  "ErrorCode": 400,
              -  "ErrorMessage": "Bad request"
              +  "ErrorCode": 401,
              +  "ErrorMessage": "Unauthorized"
               }
               
              -

              Responses

              +

              Responses

              @@ -7191,30 +7004,18 @@

              Responses

              - + - - - - - - - - - - - -
              200 OKThe catalog is createdA fresh authentication token (aka rJWT) Inline
              400Bad RequestMissing one of the mandatory input propertyBadRequestError
              401 Unauthorized Unauthorized UnauthorizedError
              403ForbiddenOnly user with createCatalog rights can create a catalogForbiddenError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -7228,18 +7029,109 @@

              Response Schema

              - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
              » status» token string false noneStatus is successA rJWT token
              » message» profileUserDisplayProfilefalsenonenone
              »» idstringtruenoneUnique user identifier. Identifier is related to user's registration date i.e. the greatest the identifier value, the most recently registered the user is
              »» picturestringtruenoneAn http(s) url to the user's avatar picture
              »» groups[string]truenoneArray of group identifiers
              »» namestringtruenoneUser display name
              »» followersintegertruenoneNumber of user's followers
              »» followingsintegertruenoneNumber of user's followings
              »» firstname string false noneMessage informationUser firstname
              »» lastnamestringfalsenoneUser lastname
              »» biostringfalsenoneUser biography
              »» registrationdatestringfalsenoneUser registration date
              »» topicsstringfalsenoneComma separated list of user's topics of interest
              »» followedbooleanfalsenoneTrue if user is followed by requesting user
              »» followmestringfalsenoneTrue if user follows requesting user
              @@ -7247,125 +7139,70 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              STACAPI::getChildren

              -

              +

              AuthAPI::createToken

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/children \
              -  -H 'Accept: application/json'
              +curl -X GET http://127.0.0.1:5252/create?emailOrId=string \
              +  -H 'Accept: application/json' \
              +  -H 'Authorization: Bearer {access-token}'
               
               
              -

              GET /children

              -

              Get root child catalogs

              -

              List of children of this catalog

              -
              -

              Example responses

              -
              -
              -

              200 Response

              -
              -
              {
              -  "features": [
              -    {
              -      "type": "Feature",
              -      "id": "b9eeaf68-5127-53e5-97ff-ddf44984ef56",
              -      "geometry": {
              -        "type": "Polygon",
              -        "coordinates": [
              -          [
              -            [
              -              -16.34433,
              -              -36.136821
              -            ],
              -            [
              -              -16.002576,
              -              -36.14017
              -            ],
              -            [
              -              -16.003437,
              -              -36.207726
              -            ],
              -            [
              -              -16.003437,
              -              -36.207726
              -            ],
              -            [
              -              -16.073904,
              -              -36.193064
              -            ],
              -            [
              -              -16.079613,
              -              -36.194838
              -            ],
              -            [
              -              -16.343729,
              -              -36.140707
              -            ],
              -            [
              -              -16.343453,
              -              -36.137129
              -            ],
              -            [
              -              -16.34433,
              -              -36.136821
              -            ]
              -          ]
              -        ]
              -      },
              -      "collection": "S2",
              -      "properties": {
              -        "datetime": "2020-06-21T11:11:28.371000Z",
              -        "start_datetime": "2020-06-21T11:11:28.371000Z",
              -        "end_datetime": "2020-06-21T11:11:28.371000Z",
              -        "productIdentifier": "S2B_MSIL1C_20200621T111039_N0209_R008_T28HCE_20200621T132349",
              -        "updated": "2018-09-13T12:52:25.971969Z",
              -        "published": "2018-09-13T12:52:25.971969Z",
              -        "hashtags": [
              -          "ocean:SouthAtlanticOcean:3358844",
              -          "landcover:water",
              -          "location:southern",
              -          "season:winter",
              -          "collection:S2",
              -          "productType:REFLECTANCE",
              -          "processingLevel:LEVEL1C",
              -          "platform:S2B",
              -          "instrument:MSI",
              -          "year:2020",
              -          "month:06",
              -          "day:21"
              -        ],
              -        "centroid": {
              -          "type": "Point",
              -          "coordinates": [
              -            70.513407,
              -            23.006623
              -          ]
              -        },
              -        "likes": 0,
              -        "comments": 0,
              -        "liked": false
              -      },
              -      "links": [
              -        {
              -          "rel": "self",
              -          "type": "application/geo+json",
              -          "href": "https://tamn.snapplanet.io/collections/S2/items/af9f811b-f6b7-5dfc-ac43-c1d200a79088"
              -        }
              -      ],
              -      "assets": {
              -        "thumbnail": {
              -          "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/28/H/CE/2020/6/21/0/preview.jpg",
              -          "type": "image/jpeg",
              -          "role": "thumbnail"
              -        }
              -      }
              -    }
              -  ]
              +

              GET /create

              +

              Create an authentication {token}

              +

              Create an authentication token (aka rJWT) for user identified by {emailOrId}

              +

              Parameters

              + + + + + + + + + + + + + + + + + + + + + + + + + + +
              NameInTypeRequiredDescription
              emailOrIdquerystringtrueUser email or id
              durationquerystringfalseDuration of token in days (default is 1 day)
              +
              +

              Example responses

              +
              +
              +

              200 Response

              +
              +
              {
              +  "userId": 100,
              +  "duration": 100,
              +  "valid_until": "2023-05-03T11:20:13",
              +  "token": "eyJzdWIiOiIxMDAiLCJpYXQiOjE2NzQ0NzI4MTMsImV4cCI6MTY4MzExMjgxM30.5fdRS1jr0fuF7HMu2oXb0sXViom39ExI2IR_FI5WK7k"
              +}
              +
              +
              +

              401 Response

              +
              +
              {
              +  "ErrorCode": 401,
              +  "ErrorMessage": "Unauthorized"
               }
               
              -

              Responses

              +

              Responses

              @@ -7379,12 +7216,24 @@

              Responses

              - + + + + + + + + + + + + +
              200 OKList of children of the root catalogThe token is created Inline
              401UnauthorizedUnauthorizedUnauthorizedError
              403ForbiddenForbiddenForbiddenError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -7398,262 +7247,375 @@

              Response Schema

              - - + + - + - - - + + + - + - + - - - - - - - - + - + - + - + - + + +
              » features[OutputFeature]» userIdstring false noneArray of featuresUser id
              »» typestringtrue» durationintegerfalse noneAlways set to featureDuration of token in days
              »» id» valid_until stringtruenoneFeature identifier
              »» geometryobjecttruefalse noneGeometry definitionToken validity
              »»» type» token stringtruefalse noneGeometry type following GeoJSON specificationGenerated token
              + +

              AuthAPI::revokeToken

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X GET http://127.0.0.1:5252/auth/revoke/{token} \
              +  -H 'Accept: application/json' \
              +  -H 'Authorization: Bearer {access-token}'
              +
              +
              +

              GET /auth/revoke/{token}

              +

              Revoke an authentication {token}

              +

              Revoke authication token (aka rJWT). Only administrator or owne of a token can revoke it. This service should be called when user logged out from client side.

              +

              Parameters

              + + - - - - - + + + + + + + - + + - - + + +
              »»» coordinates[number]falsenoneGeometry vertices following GeoJSON specificationNameInTypeRequiredDescription
              »» collectiontokenpath string truenoneCollection identifierJWT or rJWT
              +
              +

              Example responses

              +
              +
              +

              200 Response

              +
              +
              {
              +  "status": "success",
              +  "message": "Token revoked"
              +}
              +
              +
              +

              400 Response

              +
              +
              {
              +  "ErrorCode": 400,
              +  "ErrorMessage": "Bad request"
              +}
              +
              +

              Responses

              + + - - - - - + + + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + - - - - - + + + + + +
              »» links[Link]truenoneRESTo FeatureStatusMeaningDescriptionSchema
              »»» relstringtruenoneRelationship between the feature and the linked document/resource200OKThe token is revokedInline
              »»» typestringfalsenoneMimetype of the resource400Bad RequestInvalid tokenBadRequestError
              »»» titlestringfalsenoneTitle of the resource401UnauthorizedUnauthorizedUnauthorizedError
              »»» hrefstringtruenoneUrl to the resource403ForbiddenForbiddenForbiddenError
              +

              Response Schema

              +

              Status Code 200

              + + - - - - - + + + + + + + - - - + + + - + - + - + + +
              »» assetsobjecttruenoneRESTo FeatureNameTypeRequiredRestrictionsDescription
              »» propertiesobjecttrue» statusstringfalse noneFeature properties mainly based on [OGC-13-026r8] OGC OpenSearch Extension for Earth Observation. Only non null properties are returnedStatus is success
              »»» title» message string false noneA name given to the featureMessage information
              + +

              Group

              +

              GroupAPI::getGroups

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X GET http://127.0.0.1:5252/groups \
              +  -H 'Accept: application/json' \
              +  -H 'Authorization: Bearer {access-token}'
              +
              +
              +

              GET /groups

              +

              Get groups

              +

              The list is ordered by most recently created

              +

              Parameters

              + + - - - - - + + + + + + + - + + - - + + +
              »»» descriptionstringfalsenoneA descriptipon of the featureNameInTypeRequiredDescription
              »»» datetimeqquery string falsenoneStart/end of feature life (e.g. start of acquisition for a satellite imagery) (ISO 8601 - YYYY-MM-DD-THH:MM:SSZ/YYYY-MM-DD-THH:MM:SSZ)Filter by group name
              +
              +

              Example responses

              +
              +
              +

              200 Response

              +
              +
              {
              +  "totalResults": 2,
              +  "groups": [
              +    {
              +      "id": 100,
              +      "name": "My first group",
              +      "description": "Any user can create a group.",
              +      "owner": "1919730603616371731"
              +    },
              +    {
              +      "id": 101,
              +      "name": "My second group",
              +      "description": "Any user can create a group.",
              +      "owner": "1919730603616371731"
              +    }
              +  ]
              +}
              +
              +
              +

              401 Response

              +
              +
              {
              +  "ErrorCode": 401,
              +  "ErrorMessage": "Unauthorized"
              +}
              +
              +

              Responses

              + + - - - - - + + + + + + - - - - - + + + + - - - - - + + + + + +
              »»» udpatedstringfalsenoneThe date when the feature metadata was updated (ISO 8601 - YYYY-MM-DD-THH:MM:SSZ)StatusMeaningDescriptionSchema
              »»» publishedstringfalsenoneThe date when the feature metadata was published (ISO 8601 - YYYY-MM-DD-THH:MM:SSZ)200OKList of groupsInline
              »»» hashtags[string]falsenoneArray of hashtags attached to the feature401UnauthorizedUnauthorizedUnauthorizedError
              +

              Response Schema

              +

              Status Code 200

              + + - - - - - + + + + + + + - - + + - + - - + + - + - + - + - + - - - + + + - + - + - + - + - + - - - - - - - - + - +
              »»» centroidobjectfalsenoneCentroid of the featureNameTypeRequiredRestrictionsDescription
              »»»» typestring» totalResultsinteger false noneAlways set to PointTotal number of groups
              »»»» coordinates[number]» results[OutputGroup] false noneCoordinates expressed in [longitude, latitude]Return groups
              »»» likes»» id integerfalsetrue noneNumber of likes for this featureUnique group identifier - generated by resto during group creation
              »»» commentsintegerfalse»» namestringtrue noneNumber of comments on this featureUnique name of the group - free text
              »»» owner»» description stringfalsetrue noneOwner of the feature (i.e. resto user identifier as bigint)A description for this group
              »»» status»» owner integerfalsenone[Unused]
              »»» likedbooleanfalsetrue noneTrue if the user that requests the feature likes itOwner of the group (i.e. resto user identifier as bigint)
              -

              Enumerated Values

              + +

              GroupAPI::createGroup

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X POST http://127.0.0.1:5252/groups \
              +  -H 'Content-Type: application/json' \
              +  -H 'Accept: application/json' \
              +  -H 'Authorization: Bearer {access-token}'
              +
              +
              +

              POST /groups

              +

              Create group

              +

              Create a group from name - the name must be unique

              +
              +

              Body parameter

              +
              +
              {
              +  "name": "string",
              +  "description": "string"
              +}
              +
              +

              Parameters

              - - + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + - - + + + + + - - + + + + +
              PropertyValueNameInTypeRequiredDescription
              typeFeature
              typePoint
              typeMultiPoint
              typeLineString
              typeMultiLineString
              typePolygonbodybodyobjecttrueGroup description
              typeMultiPolygon» namebodystringtrueUnique name for the group
              typeGeometryCollection» descriptionbodystringfalsenone
              - -

              STACAPI::getQueryables

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/queryables \
              -  -H 'Accept: application/json'
              -
              -
              -

              GET /queryables

              -

              Queryables for STAC API

              -

              Queryable names for the STAC API Item Search filter.

              Example responses

              @@ -7661,16 +7623,20 @@

              STACAPI::getQueryables

              200 Response

              {
              -  "$schema": "string",
              -  "$id": "string",
              -  "type": "string",
              -  "title": "string",
              -  "description": "string",
              -  "properties": {},
              -  "additionalProperties": true
              +  "status": "success",
              +  "message": "Group created",
              +  "id": 100
               }
               
              -

              Responses

              +
              +

              400 Response

              +
              +
              {
              +  "ErrorCode": 400,
              +  "ErrorMessage": "Bad request"
              +}
              +
              +

              Responses

              @@ -7684,57 +7650,71 @@

              Responses

              - - + + + + + + + +
              200 OKQueryables for STAC APIQueryablesGroup is created and unique identifier is returnedInline
              400Bad RequestThis group already existBadRequestError
              - -

              AuthAPI::createToken

              -

              +

              Rights

              +

              RightsAPI::getUserRights

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/create?emailOrId=string \
              +curl -X GET http://127.0.0.1:5252/users/{userid}/rights \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              GET /create

              -

              Create an authentication {token}

              -

              Create an authentication token (aka rJWT) for user identified by {emailOrId}

              -

              Parameters

              +

              GET /users/{userid}/rights

              +

              Get user rights

              +

              Parameters

              @@ -8202,18 +8060,11 @@

              Parameters

              - - - + + + - - - - - - - - +
              emailOrIdquerystringuseridpathinteger trueUser email or id
              durationquerystringfalseDuration of token in days (default is 1 day)User identifier
              @@ -8224,21 +8075,21 @@

              Parameters

              200 Response

              {
              -  "userId": 100,
              -  "duration": 100,
              -  "valid_until": "2023-05-03T11:20:13",
              -  "token": "eyJzdWIiOiIxMDAiLCJpYXQiOjE2NzQ0NzI4MTMsImV4cCI6MTY4MzExMjgxM30.5fdRS1jr0fuF7HMu2oXb0sXViom39ExI2IR_FI5WK7k"
              -}
              -
              -
              -

              401 Response

              -
              -
              {
              -  "ErrorCode": 401,
              -  "ErrorMessage": "Unauthorized"
              +  "createCollection": false,
              +  "deleteCollection": true,
              +  "updateCollection": true,
              +  "deleteAnyCollection": false,
              +  "updateAnyCollection": false,
              +  "createFeature": true,
              +  "updateFeature": true,
              +  "deleteFeature": true,
              +  "createAnyFeature": false,
              +  "deleteAnyFeature": false,
              +  "updateAnyFeature": false,
              +  "downloadFeature": false
               }
               
              -

              Responses

              +

              Responses

              @@ -8252,8 +8103,8 @@

              Responses

              - - + + @@ -8264,51 +8115,14 @@

              Responses

              - - - - -
              200 OKThe token is createdInlineUser rightsRights
              401
              403 ForbiddenForbiddenForbiddenError
              -

              Response Schema

              -

              Status Code 200

              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - + + + +
              NameTypeRequiredRestrictionsDescription
              » userIdstringfalsenoneUser id
              » durationintegerfalsenoneDuration of token in days
              » valid_untilstringfalsenoneToken validityForbiddenForbiddenError
              » tokenstringfalsenoneGenerated token404Not FoundResource not foundNotFoundError
              @@ -8316,21 +8130,40 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              AuthAPI::revokeToken

              -

              +

              RightsAPI::setUserRights

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/auth/revoke/{token} \
              +curl -X POST http://127.0.0.1:5252/users/{userid}/rights \
              +  -H 'Content-Type: application/json' \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              GET /auth/revoke/{token}

              -

              Revoke an authentication {token}

              -

              Revoke authication token (aka rJWT). Only administrator or owne of a token can revoke it. This service should be called when user logged out from client side.

              -

              Parameters

              +

              POST /users/{userid}/rights

              +

              Set rights for user

              +

              Set rights for a given user

              +
              +

              Body parameter

              +
              +
              {
              +  "createCollection": false,
              +  "deleteCollection": true,
              +  "updateCollection": true,
              +  "deleteAnyCollection": false,
              +  "updateAnyCollection": false,
              +  "createFeature": true,
              +  "updateFeature": true,
              +  "deleteFeature": true,
              +  "createAnyFeature": false,
              +  "deleteAnyFeature": false,
              +  "updateAnyFeature": false,
              +  "downloadFeature": false
              +}
              +
              +

              Parameters

              @@ -8343,11 +8176,18 @@

              Parameters

              - + - + - + + + + + + + +
              tokenuserid pathstringinteger trueJWT or rJWTUser identifier
              bodybodyRightstrueRights to create/udpated
              @@ -8359,7 +8199,21 @@

              Parameters

              {
                 "status": "success",
              -  "message": "Token revoked"
              +  "message": "Rights updated",
              +  "rights": {
              +    "createCollection": false,
              +    "deleteCollection": true,
              +    "updateCollection": true,
              +    "deleteAnyCollection": false,
              +    "updateAnyCollection": false,
              +    "createFeature": true,
              +    "updateFeature": true,
              +    "deleteFeature": true,
              +    "createAnyFeature": false,
              +    "deleteAnyFeature": false,
              +    "updateAnyFeature": false,
              +    "downloadFeature": false
              +  }
               }
               
              @@ -8370,7 +8224,7 @@

              Parameters

              "ErrorMessage": "Bad request" }
              -

              Responses

              +

              Responses

              @@ -8384,30 +8238,18 @@

              Responses

              - + - + - - - - - - - - - - - -
              200 OKThe token is revokedRights is created or updated Inline
              400 Bad RequestInvalid tokenRights is not set BadRequestError
              401UnauthorizedUnauthorizedUnauthorizedError
              403ForbiddenForbiddenForbiddenError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -8432,7 +8274,14 @@

              Response Schema

              - + + + + + + + +
              string false noneMessage informationRights updated
              » rightsanyfalsenoneSet rights
              @@ -8440,22 +8289,20 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              Group

              -

              GroupAPI::getGroups

              -

              +

              RightsAPI::getGroupRights

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/groups \
              +curl -X GET http://127.0.0.1:5252/groups/{id}/rights \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              GET /groups

              -

              Get groups

              -

              The list is ordered by most recently created

              -

              Parameters

              +

              GET /groups/{id}/rights

              +

              Get group rights

              +

              Parameters

              @@ -8468,11 +8315,11 @@

              Parameters

              - - - - - + + + + +
              qquerystringfalseFilter by group nameidpathintegertrueGroup identifier
              @@ -8483,32 +8330,21 @@

              Parameters

              200 Response

              {
              -  "totalResults": 2,
              -  "groups": [
              -    {
              -      "id": 100,
              -      "name": "My first group",
              -      "description": "Any user can create a group.",
              -      "owner": "1919730603616371731"
              -    },
              -    {
              -      "id": 101,
              -      "name": "My second group",
              -      "description": "Any user can create a group.",
              -      "owner": "1919730603616371731"
              -    }
              -  ]
              -}
              -
              -
              -

              401 Response

              -
              -
              {
              -  "ErrorCode": 401,
              -  "ErrorMessage": "Unauthorized"
              +  "createCollection": false,
              +  "deleteCollection": true,
              +  "updateCollection": true,
              +  "deleteAnyCollection": false,
              +  "updateAnyCollection": false,
              +  "createFeature": true,
              +  "updateFeature": true,
              +  "deleteFeature": true,
              +  "createAnyFeature": false,
              +  "deleteAnyFeature": false,
              +  "updateAnyFeature": false,
              +  "downloadFeature": false
               }
               
              -

              Responses

              +

              Responses

              @@ -8522,8 +8358,8 @@

              Responses

              - - + + @@ -8531,62 +8367,17 @@

              Responses

              - -
              200 OKList of groupsInlineUser group rightsRights
              401 Unauthorized UnauthorizedError
              -

              Response Schema

              -

              Status Code 200

              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - + + + +
              NameTypeRequiredRestrictionsDescription
              » totalResultsintegerfalsenoneTotal number of groups
              » results[OutputGroup]falsenoneReturn groups
              »» idintegertruenoneUnique group identifier - generated by resto during group creation
              »» namestringtruenoneUnique name of the group - free text
              »» descriptionstringtruenoneA description for this group403ForbiddenForbiddenForbiddenError
              »» ownerintegertruenoneOwner of the group (i.e. resto user identifier as bigint)404Not FoundResource not foundNotFoundError
              @@ -8594,30 +8385,40 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              GroupAPI::createGroup

              -

              +

              RightsAPI::setGroupRights

              +

              Code samples

              # You can also use wget
              -curl -X POST http://127.0.0.1:5252/groups \
              +curl -X POST http://127.0.0.1:5252/groups/{id}/rights \
                 -H 'Content-Type: application/json' \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              POST /groups

              -

              Create group

              -

              Create a group from name - the name must be unique

              +

              POST /groups/{id}/rights

              +

              Set rights for group

              +

              Set rights for a given group

              Body parameter

              {
              -  "name": "string",
              -  "description": "string"
              +  "createCollection": false,
              +  "deleteCollection": true,
              +  "updateCollection": true,
              +  "deleteAnyCollection": false,
              +  "updateAnyCollection": false,
              +  "createFeature": true,
              +  "updateFeature": true,
              +  "deleteFeature": true,
              +  "createAnyFeature": false,
              +  "deleteAnyFeature": false,
              +  "updateAnyFeature": false,
              +  "downloadFeature": false
               }
               
              -

              Parameters

              +

              Parameters

              @@ -8630,25 +8431,18 @@

              Parameters

              - - - + + + - + - - - - - - - - - - + + +
              bodybodyobjectidpathinteger trueGroup descriptionGroup identifier
              » name bodystringtrueUnique name for the group
              » description bodystringfalsenoneRightstrueRights to create/udpated
              @@ -8660,8 +8454,21 @@

              Parameters

              {
                 "status": "success",
              -  "message": "Group created",
              -  "id": 100
              +  "message": "Rights updated",
              +  "rights": {
              +    "createCollection": false,
              +    "deleteCollection": true,
              +    "updateCollection": true,
              +    "deleteAnyCollection": false,
              +    "updateAnyCollection": false,
              +    "createFeature": true,
              +    "updateFeature": true,
              +    "deleteFeature": true,
              +    "createAnyFeature": false,
              +    "deleteAnyFeature": false,
              +    "updateAnyFeature": false,
              +    "downloadFeature": false
              +  }
               }
               
              @@ -8672,7 +8479,7 @@

              Parameters

              "ErrorMessage": "Bad request" }
              -

              Responses

              +

              Responses

              @@ -8686,18 +8493,18 @@

              Responses

              - + - +
              200 OKGroup is created and unique identifier is returnedRights is created or updated Inline
              400 Bad RequestThis group already existRights is not set BadRequestError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -8722,14 +8529,14 @@

              Response Schema

              - + - - + + - +
              string false noneGroup createdRights updated
              » idinteger» rightsany false noneNewly created group identifierCreated/update rights
              @@ -8737,20 +8544,121 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              GroupAPI::getGroup

              -

              +

              STAC

              +

              STACAPI::getCatalogs

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/groups/{id} \
              +curl -X GET http://127.0.0.1:5252/catalogs/* \
              +  -H 'Accept: application/json'
              +
              +
              +

              GET /catalogs/*

              +

              Get STAC catalogs

              +

              Get STAC catalogs

              +
              +

              Example responses

              +
              +
              +

              200 Response

              +
              +
              {
              +  "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"
              +}
              +
              +

              Responses

              + + + + + + + + + + + + + + + + + + + + + + + +
              StatusMeaningDescriptionSchema
              200OKSTAC catalog definition - contains links to child catalogs and/or itemsCatalog
              404Not FoundNot foundNone
              + +

              STACAPI::updateCatalog

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X PUT http://127.0.0.1:5252/catalogs/* \
              +  -H 'Content-Type: application/json' \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              GET /groups/{id}

              -

              Get group

              -

              Parameters

              +

              PUT /catalogs/*

              +

              Update catalog

              +

              Update catalog

              +
              +

              Body parameter

              +
              +
              {
              +  "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"
              +}
              +
              +

              Parameters

              @@ -8763,11 +8671,18 @@

              Parameters

              - + - + - + + + + + + + +
              idcatalogId pathintegerstring trueGroup identifierCatalog identifier
              bodybodyCatalogtrueCatalog fields to be update limited to title and description
              @@ -8778,13 +8693,19 @@

              Parameters

              200 Response

              {
              -  "id": 100,
              -  "name": "My first group",
              -  "description": "Any user can create a group.",
              -  "owner": "1919730603616371731"
              +  "status": "success",
              +  "message": "Catalog updated"
               }
               
              -

              Responses

              +
              +

              401 Response

              +
              +
              {
              +  "ErrorCode": 401,
              +  "ErrorMessage": "Unauthorized"
              +}
              +
              +

              Responses

              @@ -8798,8 +8719,8 @@

              Responses

              - - + + @@ -8810,36 +8731,93 @@

              Responses

              - + - +
              200 OKUser groupOutputGroupCatalog is updatedInline
              401
              403 ForbiddenForbiddenOnly user with updateCatalog rights can update a catalog ForbiddenError
              404 Not FoundResource not foundNot found NotFoundError
              +

              Response Schema

              +

              Status Code 200

              + + + + + + + + + + + + + + + + + + + + + + + + + + +
              NameTypeRequiredRestrictionsDescription
              » statusstringfalsenoneStatus is success
              » messagestringfalsenoneMessage information
              -

              GroupAPI::deleteGroup

              -

              +

              STACAPI::addCatalog

              +

              Code samples

              # You can also use wget
              -curl -X DELETE http://127.0.0.1:5252/groups/{id} \
              +curl -X POST http://127.0.0.1:5252/catalogs/* \
              +  -H 'Content-Type: application/json' \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              DELETE /groups/{id}

              -

              Delete group

              -

              Only administrator and owner of a group can delete it

              -

              Parameters

              +

              POST /catalogs/*

              +

              Add a STAC catalog

              +

              Add a STAC catalog

              +
              +

              Body parameter

              +
              +
              {
              +  "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"
              +}
              +
              +

              Parameters

              @@ -8852,11 +8830,11 @@

              Parameters

              - - - + + + - +
              idpathstringbodybodyCatalog trueGroup identifierA valid STAC Catalog
              @@ -8868,7 +8846,7 @@

              Parameters

              {
                 "status": "success",
              -  "message": "Delete group 1919831313561420822"
              +  "message": "Catalog created"
               }
               
              @@ -8879,7 +8857,7 @@

              Parameters

              "ErrorMessage": "Bad request" } -

              Responses

              +

              Responses

              @@ -8893,30 +8871,30 @@

              Responses

              - + - + + + + + + + - + - - - - - -
              200 OKThe group is deleteThe catalog is created Inline
              400 Bad RequestMissing mandatory group identifierMissing one of the mandatory input property BadRequestError
              401UnauthorizedUnauthorizedUnauthorizedError
              403 ForbiddenForbiddenOnly user with createCatalog rights can create a catalog ForbiddenError
              404Not FoundGroup not foundNotFoundError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -8949,29 +8927,21 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              GroupAPI::addUser

              -

              +

              STACAPI::removeCatalog

              +

              Code samples

              # You can also use wget
              -curl -X POST http://127.0.0.1:5252/groups/{id}/users \
              -  -H 'Content-Type: application/json' \
              +curl -X DELETE http://127.0.0.1:5252/catalogs/* \
                 -H 'Accept: application/json' \
                 -H 'Authorization: Bearer {access-token}'
               
               
              -

              POST /groups/{id}/users

              -

              Add a user

              -

              Add a user to a group

              -
              -

              Body parameter

              -
              -
              {
              -  "userid": "string"
              -}
              -
              -

              Parameters

              +

              DELETE /catalogs/*

              +

              Delete catalog

              +

              Delete catalog

              +

              Parameters

              @@ -8984,25 +8954,18 @@

              Parameters

              - + - - - - - - - - + - + - - - - - + + + + +
              idcatalogId pathintegertrueGroup identifier
              bodybodyobjectstring trueUser infoCatalog identifier
              » useridbodystringtrueUser identifierforcequerybooleanfalseForce catalog removal even if this catalog has child. In this case, catalogs childs are attached to the remove catalog parent
              @@ -9014,10 +8977,19 @@

              Parameters

              {
                 "status": "success",
              -  "message": "User added"
              +  "message": "Catalog deleted",
              +  "featuresUpdated": 345
               }
               
              -

              Responses

              +
              +

              401 Response

              +
              +
              {
              +  "ErrorCode": 401,
              +  "ErrorMessage": "Unauthorized"
              +}
              +
              +

              Responses

              @@ -9031,12 +9003,30 @@

              Responses

              - + + + + + + + + + + + + + + + + + + +
              200 OKUser is addedCatalog deleted Inline
              401UnauthorizedUnauthorizedUnauthorizedError
              403ForbiddenOnly user with deleteCatalog rights can delete a catalogForbiddenError
              404Not FoundNot foundNotFoundError
              -

              Response Schema

              +

              Response Schema

              Status Code 200

              @@ -9061,7 +9051,7 @@

              Response Schema

              - +
              string false noneUser addedMessage information
              @@ -9069,21 +9059,19 @@

              Response Schema

              To perform this operation, you must be authenticated by means of one of the following methods: basicAuth & bearerAuth & queryAuth -

              Rights

              -

              RightsAPI::getUserRights

              -

              +

              STACAPI::getAsset

              +

              Code samples

              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/users/{userid}/rights \
              -  -H 'Accept: application/json' \
              -  -H 'Authorization: Bearer {access-token}'
              +curl -X GET http://127.0.0.1:5252/assets/{urlInBase64}
               
               
              -

              GET /users/{userid}/rights

              -

              Get user rights

              -

              Parameters

              +

              GET /assets/{urlInBase64}

              +

              Download asset

              +

              Return the asset href within an HTTP 301 Redirect message. This allows to keep track of download of external assets in resto statistics

              +

              Parameters

              @@ -9096,14 +9084,55 @@

              Parameters

              - + - + - + + + +
              useridurlInBase64 pathintegerstring trueUser identifierAsset url encoded in Base64
              +

              Responses

              + + + + + + + + + + + + + + + + + + + + +
              StatusMeaningDescriptionSchema
              301Moved PermanentlyHTTP/1.1 301 Moved PermanentlyNone
              400Bad RequestInvalid base64 encoded urlNone
              + +

              STACAPI::getChildren

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X GET http://127.0.0.1:5252/children \
              +  -H 'Accept: application/json'
              +
              +
              +

              GET /children

              +

              Get root child catalogs

              +

              List of children of this catalog

              Example responses

              @@ -9111,21 +9140,105 @@

              Parameters

              200 Response

              {
              -  "createCollection": false,
              -  "deleteCollection": true,
              -  "updateCollection": true,
              -  "deleteAnyCollection": false,
              -  "updateAnyCollection": false,
              -  "createFeature": true,
              -  "updateFeature": true,
              -  "deleteFeature": true,
              -  "createAnyFeature": false,
              -  "deleteAnyFeature": false,
              -  "updateAnyFeature": false,
              -  "downloadFeature": false
              +  "features": [
              +    {
              +      "type": "Feature",
              +      "id": "b9eeaf68-5127-53e5-97ff-ddf44984ef56",
              +      "geometry": {
              +        "type": "Polygon",
              +        "coordinates": [
              +          [
              +            [
              +              -16.34433,
              +              -36.136821
              +            ],
              +            [
              +              -16.002576,
              +              -36.14017
              +            ],
              +            [
              +              -16.003437,
              +              -36.207726
              +            ],
              +            [
              +              -16.003437,
              +              -36.207726
              +            ],
              +            [
              +              -16.073904,
              +              -36.193064
              +            ],
              +            [
              +              -16.079613,
              +              -36.194838
              +            ],
              +            [
              +              -16.343729,
              +              -36.140707
              +            ],
              +            [
              +              -16.343453,
              +              -36.137129
              +            ],
              +            [
              +              -16.34433,
              +              -36.136821
              +            ]
              +          ]
              +        ]
              +      },
              +      "collection": "S2",
              +      "properties": {
              +        "datetime": "2020-06-21T11:11:28.371000Z",
              +        "start_datetime": "2020-06-21T11:11:28.371000Z",
              +        "end_datetime": "2020-06-21T11:11:28.371000Z",
              +        "productIdentifier": "S2B_MSIL1C_20200621T111039_N0209_R008_T28HCE_20200621T132349",
              +        "updated": "2018-09-13T12:52:25.971969Z",
              +        "published": "2018-09-13T12:52:25.971969Z",
              +        "hashtags": [
              +          "ocean:SouthAtlanticOcean:3358844",
              +          "landcover:water",
              +          "location:southern",
              +          "season:winter",
              +          "collection:S2",
              +          "productType:REFLECTANCE",
              +          "processingLevel:LEVEL1C",
              +          "platform:S2B",
              +          "instrument:MSI",
              +          "year:2020",
              +          "month:06",
              +          "day:21"
              +        ],
              +        "centroid": {
              +          "type": "Point",
              +          "coordinates": [
              +            70.513407,
              +            23.006623
              +          ]
              +        },
              +        "likes": 0,
              +        "comments": 0,
              +        "liked": false
              +      },
              +      "links": [
              +        {
              +          "rel": "self",
              +          "type": "application/geo+json",
              +          "href": "https://tamn.snapplanet.io/collections/S2/items/af9f811b-f6b7-5dfc-ac43-c1d200a79088"
              +        }
              +      ],
              +      "assets": {
              +        "thumbnail": {
              +          "href": "https://roda.sentinel-hub.com/sentinel-s2-l1c/tiles/28/H/CE/2020/6/21/0/preview.jpg",
              +          "type": "image/jpeg",
              +          "role": "thumbnail"
              +        }
              +      }
              +    }
              +  ]
               }
               
              -

              Responses

              +

              Responses

              @@ -9139,383 +9252,298 @@

              Responses

              - - - - - - - - - - - - - - - - - - - - + +
              200 OKUser rightsRights
              401UnauthorizedUnauthorizedUnauthorizedError
              403ForbiddenForbiddenForbiddenError
              404Not FoundResource not foundNotFoundErrorList of children of the root catalogInline
              - -

              RightsAPI::setUserRights

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X POST http://127.0.0.1:5252/users/{userid}/rights \
              -  -H 'Content-Type: application/json' \
              -  -H 'Accept: application/json' \
              -  -H 'Authorization: Bearer {access-token}'
              -
              -
              -

              POST /users/{userid}/rights

              -

              Set rights for user

              -

              Set rights for a given user

              -
              -

              Body parameter

              -
              -
              {
              -  "createCollection": false,
              -  "deleteCollection": true,
              -  "updateCollection": true,
              -  "deleteAnyCollection": false,
              -  "updateAnyCollection": false,
              -  "createFeature": true,
              -  "updateFeature": true,
              -  "deleteFeature": true,
              -  "createAnyFeature": false,
              -  "deleteAnyFeature": false,
              -  "updateAnyFeature": false,
              -  "downloadFeature": false
              -}
              -
              -

              Parameters

              +

              Response Schema

              +

              Status Code 200

              - + - - - + + + + + + + + + - + + - - - + + - + + - -
              NameIn Type RequiredRestrictions Description
              useridpathinteger» features[OutputFeature]falsenoneArray of features
              »» typestring trueUser identifiernoneAlways set to feature
              bodybodyRights»» idstring trueRights to create/udpatednoneFeature identifier
              -
              -

              Example responses

              -
              -
              -

              200 Response

              -
              -
              {
              -  "status": "success",
              -  "message": "Rights updated",
              -  "rights": {
              -    "createCollection": false,
              -    "deleteCollection": true,
              -    "updateCollection": true,
              -    "deleteAnyCollection": false,
              -    "updateAnyCollection": false,
              -    "createFeature": true,
              -    "updateFeature": true,
              -    "deleteFeature": true,
              -    "createAnyFeature": false,
              -    "deleteAnyFeature": false,
              -    "updateAnyFeature": false,
              -    "downloadFeature": false
              -  }
              -}
              -
              -
              -

              400 Response

              -
              -
              {
              -  "ErrorCode": 400,
              -  "ErrorMessage": "Bad request"
              -}
              -
              -

              Responses

              - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + - - - - + + + + + - -
              StatusMeaningDescriptionSchema»» geometryobjecttruenoneGeometry definition
              »»» typestringtruenoneGeometry type following GeoJSON specification
              »»» coordinates[number]falsenoneGeometry vertices following GeoJSON specification
              »» collectionstringtruenoneCollection identifier
              »» links[Link]truenoneRESTo Feature
              »»» relstringtruenoneRelationship between the feature and the linked document/resource
              »»» typestringfalsenoneMimetype of the resource
              »»» titlestringfalsenoneTitle of the resource
              »»» hrefstringtruenoneUrl to the resource
              »» assetsobjecttruenoneRESTo Feature
              »» propertiesobjecttruenoneFeature properties mainly based on [OGC-13-026r8] OGC OpenSearch Extension for Earth Observation. Only non null properties are returned
              »»» titlestringfalsenoneA name given to the feature
              »»» descriptionstringfalsenoneA descriptipon of the feature
              »»» datetimestringfalsenoneStart/end of feature life (e.g. start of acquisition for a satellite imagery) (ISO 8601 - YYYY-MM-DD-THH:MM:SSZ/YYYY-MM-DD-THH:MM:SSZ)
              »»» udpatedstringfalsenoneThe date when the feature metadata was updated (ISO 8601 - YYYY-MM-DD-THH:MM:SSZ)
              »»» publishedstringfalsenoneThe date when the feature metadata was published (ISO 8601 - YYYY-MM-DD-THH:MM:SSZ)
              200OKRights is created or updatedInline»»» hashtags[string]falsenoneArray of hashtags attached to the feature
              400Bad RequestRights is not setBadRequestError»»» centroidobjectfalsenoneCentroid of the feature
              -

              Response Schema

              -

              Status Code 200

              - - - - - - - + + + + + - - - - + + - + - - + + - + - - + + - + - -
              NameTypeRequiredRestrictionsDescription»»»» typestringfalsenoneAlways set to Point
              » statusstring»»»» coordinates[number] false noneStatus is successCoordinates expressed in [longitude, latitude]
              » messagestring»»» likesinteger false noneRights updatedNumber of likes for this feature
              » rightsany»»» commentsinteger false noneSet rightsNumber of comments on this feature
              - -

              RightsAPI::getGroupRights

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X GET http://127.0.0.1:5252/groups/{id}/rights \
              -  -H 'Accept: application/json' \
              -  -H 'Authorization: Bearer {access-token}'
              -
              -
              -

              GET /groups/{id}/rights

              -

              Get group rights

              -

              Parameters

              - - - - - - - + + + + + - - - - + - - + + + + + + + + + +
              NameInTypeRequiredDescription»»» ownerstringfalsenoneOwner of the feature (i.e. resto user identifier as bigint)
              idpath»»» status integertrueGroup identifierfalsenone[Unused]
              »»» likedbooleanfalsenoneTrue if the user that requests the feature likes it
              -
              -

              Example responses

              -
              -
              -

              200 Response

              -
              -
              {
              -  "createCollection": false,
              -  "deleteCollection": true,
              -  "updateCollection": true,
              -  "deleteAnyCollection": false,
              -  "updateAnyCollection": false,
              -  "createFeature": true,
              -  "updateFeature": true,
              -  "deleteFeature": true,
              -  "createAnyFeature": false,
              -  "deleteAnyFeature": false,
              -  "updateAnyFeature": false,
              -  "downloadFeature": false
              -}
              -
              -

              Responses

              +

              Enumerated Values

              - - - - + + - - - - + + - - - - + + - - - - + + - - - - + + - -
              StatusMeaningDescriptionSchemaPropertyValue
              200OKUser group rightsRightstypeFeature
              401UnauthorizedUnauthorizedUnauthorizedErrortypePoint
              403ForbiddenForbiddenForbiddenErrortypeMultiPoint
              404Not FoundResource not foundNotFoundErrortypeLineString
              - -

              RightsAPI::setGroupRights

              -

              -
              -

              Code samples

              -
              -
              # You can also use wget
              -curl -X POST http://127.0.0.1:5252/groups/{id}/rights \
              -  -H 'Content-Type: application/json' \
              -  -H 'Accept: application/json' \
              -  -H 'Authorization: Bearer {access-token}'
              -
              -
              -

              POST /groups/{id}/rights

              -

              Set rights for group

              -

              Set rights for a given group

              -
              -

              Body parameter

              -
              -
              {
              -  "createCollection": false,
              -  "deleteCollection": true,
              -  "updateCollection": true,
              -  "deleteAnyCollection": false,
              -  "updateAnyCollection": false,
              -  "createFeature": true,
              -  "updateFeature": true,
              -  "deleteFeature": true,
              -  "createAnyFeature": false,
              -  "deleteAnyFeature": false,
              -  "updateAnyFeature": false,
              -  "downloadFeature": false
              -}
              -
              -

              Parameters

              - - - - - - - + + - - - - - - - + + - - - - - + + + + + +
              NameInTypeRequiredDescriptiontypeMultiLineString
              idpathintegertrueGroup identifiertypePolygon
              bodybodyRightstrueRights to create/udpatedtypeMultiPolygon
              typeGeometryCollection
              -
              -

              Example responses

              -
              -
              -

              200 Response

              -
              -
              {
              -  "status": "success",
              -  "message": "Rights updated",
              -  "rights": {
              -    "createCollection": false,
              -    "deleteCollection": true,
              -    "updateCollection": true,
              -    "deleteAnyCollection": false,
              -    "updateAnyCollection": false,
              -    "createFeature": true,
              -    "updateFeature": true,
              -    "deleteFeature": true,
              -    "createAnyFeature": false,
              -    "deleteAnyFeature": false,
              -    "updateAnyFeature": false,
              -    "downloadFeature": false
              -  }
              -}
              +
              +

              STACAPI::getQueryables

              +

              +
              +

              Code samples

              +
              +
              # You can also use wget
              +curl -X GET http://127.0.0.1:5252/queryables \
              +  -H 'Accept: application/json'
              +
               
              +

              GET /queryables

              +

              Queryables for STAC API

              +

              Queryable names for the STAC API Item Search filter.

              -

              400 Response

              +

              Example responses

              +
              +
              +

              200 Response

              {
              -  "ErrorCode": 400,
              -  "ErrorMessage": "Bad request"
              +  "$schema": "string",
              +  "$id": "string",
              +  "type": "string",
              +  "title": "string",
              +  "description": "string",
              +  "properties": {},
              +  "additionalProperties": true
               }
               
              -

              Responses

              +

              Responses

              @@ -9529,56 +9557,13 @@

              Responses

              - - - - - - - - - - -
              200 OKRights is created or updatedInline
              400Bad RequestRights is not setBadRequestError
              -

              Response Schema

              -

              Status Code 200

              - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
              NameTypeRequiredRestrictionsDescription
              » statusstringfalsenoneStatus is success
              » messagestringfalsenoneRights updated
              » rightsanyfalsenoneCreated/update rightsQueryables for STAC APIQueryables
              -