Skip to content

Malformed links when setting root-path through uvicorn #220

@sunu

Description

@sunu

When root-path is set through uvicorn's --root-path flag, some of the links returned from stac-fastapi-pgstac are malformed and contain the root path twice.

Steps to reproduce

To reproduce this behavior locally, apply the following diff to the docker-compose file:

diff --git a/docker-compose.yml b/docker-compose.yml
index 2dcafa9..ae2628c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -89,7 +89,8 @@ services:
   app-nginx:
     extends:
       service: app
-    command: bash -c "./scripts/wait-for-it.sh database:5432 && uvicorn stac_fastapi.pgstac.app:app --host 0.0.0.0 --port 8082 --proxy-headers --forwarded-allow-ips=*"
+    container_name: stac-fastapi-pgstac-nginx
+    command: bash -c "./scripts/wait-for-it.sh database:5432 && uvicorn stac_fastapi.pgstac.app:app --host 0.0.0.0 --port 8082 --proxy-headers --forwarded-allow-ips=* --root-path=/api/v1/pgstac"
     environment:
       - ROOT_PATH=/api/v1/pgstac

This sets the --root-path flag.

Now start the app-nginx service: docker compose up --build app-nginx nginx

Inspect the links returned from the collections endpoint:

curl -s http://0.0.0.0:8080/api/v1/pgstac/collections | jq '.links'
[
  {
    "rel": "root",
    "type": "application/json",
    "href": "http://0.0.0.0:8080/api/v1/pgstac/"
  },
  {
    "rel": "self",
    "type": "application/json",
    "href": "http://0.0.0.0:8080/api/v1/pgstac/api/v1/pgstac/collections"
  }
]

You would notice that the href in the second link entry is malformed and contains the root path /api/v1/pgstac twice.

Probable cause

There was a refactoring into how root path is handled by uvicorn in Kludex/uvicorn#2213
This was released as part of uvicorn 0.26.0 (https://github.com/encode/uvicorn/blob/master/docs/release-notes.md#0260-january-16-2024)
This was integrated into stac-fastapi-pgstac in 7135059 and released as part of the 4.0.0 release in #196

After uvicorn 0.26.0 and stac-fastapi-pgstac 4.0.0, all requests going through uvicorn go to /{root-path}/requested-path. This also explains behavior in developmentseed/eoapi-k8s#195 (comment)

Possible fix

One way to get around this is of course to not set root path through uvicorn's --root-path flag and use the ROOT_PATH env var instead. But I don't think that's a good solution.

Here's a patch for how to handle this during link generation:

diff --git a/stac_fastapi/pgstac/models/links.py b/stac_fastapi/pgstac/models/links.py
index 6697488..5d72de7 100644
--- a/stac_fastapi/pgstac/models/links.py
+++ b/stac_fastapi/pgstac/models/links.py
@@ -51,7 +51,30 @@ class BaseLinks:
     @property
     def url(self):
         """Get the current request url."""
-        url = urljoin(str(self.request.base_url), self.request.url.path.lstrip("/"))
+        # base_url is of the form http{s}://host{:port}/{root_path}
+        base_url = self.request.base_url
+        path = self.request.url.path
+        # root path can be set in the request scope in two different ways:
+        # by uvicorn when running with --root-path
+        # or by FastAPI when running with FastAPI(root_path="...")
+        # When root path is set by uvicorn, request.url.path will have the root path prefix.
+        # eg. if root path is "/api" and the path is "/collections",
+        # the request.url.path will be "/api/collections"
+        # We need to remove the root path prefix from the path before
+        # joining the base_url and path to get the full url to avoid
+        # having root_path twice in the url
+        if self.request.scope.get("root_path") and not self.request.app.root_path:
+            # self.request.app.root_path is set by FastAPI when running with FastAPI(root_path="...")
+            # If self.request.app.root_path is not set but self.request.scope.get("root_path") is set,
+            # then the root path is set by uvicorn
+            # So we need to remove the root path prefix from the path before
+            # joining the base_url and path to get the full url
+            root_path = self.request.scope["root_path"]
+            if path.startswith(root_path):
+                path = path[len(root_path) :]
+        path = path.lstrip("/")
+
+        url = urljoin(str(base_url), path)
         if qs := self.request.url.query:
             url += f"?{qs}"

We deployed an image with this patch applied for IFRC Montandon and it worked quite well. @vincentsarago let me know if this looks ok and I'd love to create a PR

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions