Repository: CS-SI/eodag
Pull request #813 (2023-09-07 10:14:04)

fix(server): correct links in stac model
Correct the links in the STAC model. It fix the links on the following endpoints:
- /collections
- /collections/{collection_id}
- /collections/{collection_id}/items
- /collections/{collection_id}/items/{item_id}

github-actions[bot]: ## Test Results
    2 files   -        2      2 suites   - 2   2m 2s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "duration of all tests") - 2m 28s
413 tests ±       0  410 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "passed tests")  -        1  2 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "skipped / disabled tests") ±  0  1 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "failed tests") +1 
559 runs   - 1 093  556 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "passed tests")  - 1 028  2 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "skipped / disabled tests")  - 66  1 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v2.9.0/README.md#the-symbols "failed tests") +1 

For more details on these failures, see [this check](https://github.com/CS-SI/eodag/runs/16577535652).

Results for commit a812e859. ± Comparison against base commit 0b62dab3.

[test-results]:data:application/gzip;base64,H4sIAK6j+WQC/02MSQ7DIBAEv2JxzoHBHmLymQhhkEbxErGcovw9QGzsY1Wr68MczTawRyduHQuJYoMpeR1pWzOCKCJPsYwD9Ac9QzKmKn6qF733xF84TXOJNGG933w25ePTWpqIaocjiSibOYuVL8HK157ZloViBqZHEHZENRmwSplJYu/GHvRdaukG7TgoBzhw9v0BnPH4xgUBAAA=

github-actions[bot]: <strong>Code Coverage (Ubuntu)</strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `82%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against 4c113e8268e5b8d6cfa69fb248a90bfe65ea7e71 </p>
github-actions[bot]: <strong>Code Coverage (Windows)</strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `75%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against 4c113e8268e5b8d6cfa69fb248a90bfe65ea7e71 </p>

Patch:
diff --git a/eodag/resources/stac.yml b/eodag/resources/stac.yml
index 1b98eae8..140454ac 100644
--- a/eodag/resources/stac.yml
+++ b/eodag/resources/stac.yml
@@ -29,7 +29,7 @@ landing_page:
   links:
     - rel: self
       type: "application/json"
-      href: "{catalog[root]}"
+      href: "{catalog[root]}/"
     - rel: service-desc
       type: "application/vnd.oai.openapi+json;version=3.0"
       href: "{catalog[root]}/api"
@@ -66,11 +66,11 @@ collections:
   links:
     - rel: self
       title: collections
-      href: /collections
+      href: "{collections[url]}"
     - rel: root
-      href: /
+      href: "{collections[root]}/"
     - rel: parent
-      href: /
+      href: "{collections[root]}/"
   collections:
     - "{collection}"
   stac_version: "{stac_version}"
@@ -84,12 +84,12 @@ collection:
   links:
     - rel: self
       title: "{collection[id]}"
-      href: "{collection[root]}/collections/{collection[id]}"
+      href: "{collection[url]}"
     - rel: root
-      href: "{collection[root]}"
+      href: "{collection[root]}/"
     - rel: items
       title: "items"
-      href: "{collection[root]}/collections/{collection[id]}/items"
+      href: "{collection[url]}/items"
   extent:
     spatial:
       bbox:
@@ -140,7 +140,7 @@ items:
       title: "items"
       href: "{catalog[url]}/items"
     - rel: root
-      href: /
+      href: "{catalog[root]}/"
     - rel: parent
       title: "{catalog[id]}"
       href: "{catalog[url]}"
@@ -208,13 +208,13 @@ item:
       title: "{item[id]}"
       href: "{catalog[url]}/items/{item[id]}"
     - rel: root
-      href: /
+      href: "{catalog[root]}/"
     - rel: parent
       title: "{catalog[id]}"
       href: "{catalog[url]}"
     - rel: collection
       title: "{item[collection]}"
-      href: "/collections/{item[collection]}"
+      href: "{catalog[url]}"
   assets:
     downloadLink:
         title: 'Download link'
diff --git a/eodag/rest/server.py b/eodag/rest/server.py
index 13e35db5..5625e711 100755
--- a/eodag/rest/server.py
+++ b/eodag/rest/server.py
@@ -380,7 +380,7 @@ def stac_collections_items(collection_id, request: Request):
 @router.get("/collections/{collection_id}", tags=["Capabilities"])
 def collection_by_id(collection_id, request: Request):
     """STAC collection by id"""
-    url = request.state.url
+    url = request.state.url_root + "/collections"
     url_root = request.state.url_root
 
     body = {}
diff --git a/eodag/rest/stac.py b/eodag/rest/stac.py
index 39d65103..34ca0394 100644
--- a/eodag/rest/stac.py
+++ b/eodag/rest/stac.py
@@ -578,7 +578,8 @@ class StacCollection(StacCommon):
             # parse f-strings
             format_args = deepcopy(self.stac_config)
             format_args["collection"] = dict(
-                product_type_collection, **{"url": self.url, "root": self.root}
+                product_type_collection,
+                **{"url": f"{self.url}/{product_type['ID']}", "root": self.root},
             )
             product_type_collection = format_dict_items(
                 product_type_collection, **format_args
@@ -599,6 +600,14 @@ class StacCollection(StacCommon):
         collections = deepcopy(self.stac_config["collections"])
         collections["collections"] = self.__get_collection_list(filters)
 
+        # # parse f-strings
+        format_args = deepcopy(self.stac_config)
+        format_args["collections"].update({"url": self.url, "root": self.root})
+
+        collections["links"] = [
+            format_dict_items(link, **format_args) for link in collections["links"]
+        ]
+
         collections["links"] += [
             {
                 "rel": "child",


Test patch:
diff --git a/tests/units/test_http_server.py b/tests/units/test_http_server.py
index de3db507..6edda81d 100644
--- a/tests/units/test_http_server.py
+++ b/tests/units/test_http_server.py
@@ -85,20 +85,62 @@ class RequestTestCase(unittest.TestCase):
         self.app = TestClient(self.eodag_http_server.app)
 
     def test_route(self):
-        self._request_valid("/")
+        result = self._request_valid("/", check_links=False)
+
+        # check links (root specfic)
+        self.assertIsInstance(result, dict)
+        self.assertIn("links", result, f"links not found in {str(result)}")
+        self.assertIsInstance(result["links"], list)
+        links = result["links"]
+
+        known_rel = [
+            "self",
+            "root",
+            "parent",
+            "child",
+            "items",
+            "service-desc",
+            "service-doc",
+            "conformance",
+            "search",
+            "data",
+        ]
+        required_links_rel = ["self"]
+
+        for link in links:
+            # known relations
+            self.assertIn(link["rel"], known_rel)
+            # must start with app base-url
+            assert link["href"].startswith(str(self.app.base_url))
+            if link["rel"] != "search":
+                # must be valid
+                self._request_valid_raw(link["href"])
+            else:
+                # missing collection
+                self._request_not_valid(link["href"])
+
+            if link["rel"] in required_links_rel:
+                required_links_rel.remove(link["rel"])
+
+        # required relations
+        self.assertEqual(
+            len(required_links_rel),
+            0,
+            f"missing {required_links_rel} relation(s) in {links}",
+        )
 
     def test_forward(self):
         response = self.app.get("/", follow_redirects=True)
         self.assertEqual(200, response.status_code)
         resp_json = json.loads(response.content.decode("utf-8"))
-        self.assertEqual(resp_json["links"][0]["href"], "http://testserver")
+        self.assertEqual(resp_json["links"][0]["href"], "http://testserver/")
 
         response = self.app.get(
             "/", follow_redirects=True, headers={"Forwarded": "host=foo;proto=https"}
         )
         self.assertEqual(200, response.status_code)
         resp_json = json.loads(response.content.decode("utf-8"))
-        self.assertEqual(resp_json["links"][0]["href"], "https://foo")
+        self.assertEqual(resp_json["links"][0]["href"], "https://foo/")
 
         response = self.app.get(
             "/",
@@ -107,7 +149,7 @@ class RequestTestCase(unittest.TestCase):
         )
         self.assertEqual(200, response.status_code)
         resp_json = json.loads(response.content.decode("utf-8"))
-        self.assertEqual(resp_json["links"][0]["href"], "httpz://bar")
+        self.assertEqual(resp_json["links"][0]["href"], "httpz://bar/")
 
     @mock.patch(
         "eodag.rest.utils.eodag_api.search",
@@ -292,6 +334,7 @@ class RequestTestCase(unittest.TestCase):
         protocol="GET",
         post_data=None,
         search_call_count=None,
+        check_links=True,
     ):
         response = self._request_valid_raw(
             url,
@@ -302,7 +345,52 @@ class RequestTestCase(unittest.TestCase):
         )
 
         # Assert response format is GeoJSON
-        return geojson.loads(response.content.decode("utf-8"))
+        result = geojson.loads(response.content.decode("utf-8"))
+
+        if check_links:
+            self.assert_links_valid(result)
+
+        return result
+
+    def assert_links_valid(self, element):
+        """Checks that element links are valid"""
+        self.assertIsInstance(element, dict)
+        self.assertIn("links", element, f"links not found in {str(element)}")
+        self.assertIsInstance(element["links"], list)
+        links = element["links"]
+
+        known_rel = [
+            "self",
+            "root",
+            "parent",
+            "child",
+            "items",
+            "service-desc",
+            "service-doc",
+            "conformance",
+            "search",
+            "data",
+            "collection",
+        ]
+        required_links_rel = ["self", "root"]
+
+        for link in links:
+            # known relations
+            self.assertIn(link["rel"], known_rel)
+            # must start with app base-url
+            assert link["href"].startswith(str(self.app.base_url))
+            # must be valid
+            self._request_valid_raw(link["href"])
+
+            if link["rel"] in required_links_rel:
+                required_links_rel.remove(link["rel"])
+
+        # required relations
+        self.assertEqual(
+            len(required_links_rel),
+            0,
+            f"missing {required_links_rel} relation(s) in {links}",
+        )
 
     def _request_not_valid(self, url):
         response = self.app.get(url, follow_redirects=True)
@@ -310,7 +398,7 @@ class RequestTestCase(unittest.TestCase):
 
         self.assertEqual(400, response.status_code)
         self.assertIn("description", response_content)
-        self.assertIn("invalid", response_content["description"])
+        self.assertIn("invalid", response_content["description"].lower())
 
     def _request_not_found(self, url):
         response = self.app.get(url, follow_redirects=True)
@@ -768,16 +856,16 @@ class RequestTestCase(unittest.TestCase):
 
     def test_conformance(self):
         """Request to /conformance should return a valid response"""
-        self._request_valid("conformance")
+        self._request_valid("conformance", check_links=False)
 
     def test_service_desc(self):
         """Request to service_desc should return a valid response"""
-        service_desc = self._request_valid("api")
+        service_desc = self._request_valid("api", check_links=False)
         self.assertIn("openapi", service_desc.keys())
         self.assertIn("eodag", service_desc["info"]["title"].lower())
         self.assertGreater(len(service_desc["paths"].keys()), 0)
         # test a 2nd call (ending slash must be ignored)
-        self._request_valid("api/")
+        self._request_valid("api/", check_links=False)
 
     def test_service_doc(self):
         """Request to service_doc should return a valid response"""
@@ -786,21 +874,26 @@ class RequestTestCase(unittest.TestCase):
 
     def test_stac_extension_oseo(self):
         """Request to oseo extension should return a valid response"""
-        response = self._request_valid("/extensions/oseo/json-schema/schema.json")
+        response = self._request_valid(
+            "/extensions/oseo/json-schema/schema.json", check_links=False
+        )
         self.assertEqual(response["title"], "OpenSearch for Earth Observation")
         self.assertEqual(response["allOf"][0]["$ref"], "#/definitions/oseo")
 
     def test_queryables(self):
         """Request to /queryables should return a valid response."""
-        self._request_valid("queryables")
+        self._request_valid("queryables", check_links=False)
 
     def test_product_type_queryables(self):
         """Request to /collections/{collection_id}/queryables should return a valid response."""
-        self._request_valid(f"collections/{self.tested_product_type}/queryables")
+        self._request_valid(
+            f"collections/{self.tested_product_type}/queryables", check_links=False
+        )
 
     def test_product_type_queryables_with_provider(self):
         """Request a collection-specific list of queryables for a given provider."""
 
         self._request_valid(
-            f"collections/{self.tested_product_type}/queryables?provider=peps"
+            f"collections/{self.tested_product_type}/queryables?provider=peps",
+            check_links=False,
         )