Skip to content

Commit

Permalink
Create catch-all/fallback route for UI app (#932)
Browse files Browse the repository at this point in the history
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Peyton Murray <[email protected]>
  • Loading branch information
3 people authored Nov 19, 2024
1 parent 7db0a07 commit f2680f5
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 35 deletions.
66 changes: 37 additions & 29 deletions conda-store-server/conda_store_server/_internal/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,32 @@ async def exception_handler(request, exc):
# docker registry api specification does not support a url_prefix
app.include_router(views.router_registry)

if self.enable_ui:
if self.enable_metrics:
app.include_router(
views.router_ui,
prefix=trim_slash(self.url_prefix) + "/admin",
views.router_metrics,
prefix=trim_slash(self.url_prefix),
)

if self.additional_routes:
for path, method, func in self.additional_routes:
getattr(app, method)(path, name=func.__name__)(func)

if isinstance(self.conda_store.storage, storage.LocalStorage):
self.conda_store.storage.storage_url = (
f"{trim_slash(self.url_prefix)}/storage"
)
app.mount(
self.conda_store.storage.storage_url,
StaticFiles(directory=self.conda_store.storage.storage_path),
name="static-storage",
)

# This needs to come at the end because if the UI is enabled,
# it becomes the catch all route
if self.enable_ui:
app.include_router(
views.router_conda_store_ui,
prefix=trim_slash(self.url_prefix),
views.router_ui,
prefix=trim_slash(self.url_prefix) + "/admin",
)

# serving static files
Expand All @@ -314,15 +331,20 @@ async def exception_handler(request, exc):
name="static",
)

# convenience to redirect "/" to home page when using a prefix
# realistically this url will not be hit with a proxy + prefix
if self.url_prefix != "/":

@app.get("/")
def redirect_home(request: Request):
return RedirectResponse(request.url_for("get_conda_store_ui"))
# Redirect both "/" and `url_prefix` to the conda-store-ui React app.
# Realistically the "/" will not be hit with a proxy + prefix.
@app.get(
# Yes if url_prefix is "/" then this decorator is redundant but FastAPI doesn't seem to be bothered.
"/"
)
@app.get(self.url_prefix)
# This function name may be used by url_for() so be careful renaming it
def redirect_root_to_ui(request: Request):
return RedirectResponse(request.url_for("get_conda_store_ui"))

@app.get("/favicon.ico", include_in_schema=False)
@app.get(
trim_slash(self.url_prefix) + "/favicon.ico", include_in_schema=False
)
async def favicon():
return FileResponse(
os.path.join(
Expand All @@ -332,26 +354,12 @@ async def favicon():
)
)

if self.enable_metrics:
# Put this at the very end
app.include_router(
views.router_metrics,
views.router_conda_store_ui,
prefix=trim_slash(self.url_prefix),
)

if self.additional_routes:
for path, method, func in self.additional_routes:
getattr(app, method)(path, name=func.__name__)(func)

if isinstance(self.conda_store.storage, storage.LocalStorage):
self.conda_store.storage.storage_url = (
f"{trim_slash(self.url_prefix)}/storage"
)
app.mount(
self.conda_store.storage.storage_url,
StaticFiles(directory=self.conda_store.storage.storage_path),
name="static-storage",
)

return app

def _check_worker(self, delay=5):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ async def get_entity(request: Request, auth=Depends(get_auth)):

async def get_templates(request: Request):
return request.state.templates


async def get_url_prefix(request: Request, server=Depends(get_server)):
return server.url_prefix
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
REACT_APP_AUTH_TOKEN: "",
REACT_APP_STYLE_TYPE: "green-accent",
REACT_APP_SHOW_LOGIN_ICON: "true",
REACT_APP_API_URL: "{{ url_for('get_conda_store_ui') }}",
REACT_APP_API_URL: "{{ url_for('redirect_root_to_ui') }}",
REACT_APP_LOGIN_PAGE_URL: "{{ url_for('get_login_method') }}?next=",
REACT_APP_LOGOUT_PAGE_URL: "{{ url_for('post_logout_method') }}?next=/",
REACT_APP_URL_BASENAME: new URL("{{ url_for('get_conda_store_ui') }}").pathname
REACT_APP_URL_BASENAME: "{{ url_prefix.rstrip('/') }}{{ ui_prefix }}"
};
</script>
<script defer src="static/conda-store-ui/main.js"></script>
<link href="static/conda-store-ui/main.css" rel="stylesheet">
<script defer src="{{ url_prefix.rstrip("/") }}/static/conda-store-ui/main.js"></script>
<link href="{{ url_prefix.rstrip("/") }}/static/conda-store-ui/main.css" rel="stylesheet">
<link href="{{ url_prefix.rstrip("/") }}/favicon.ico" rel="icon">
</head>
<body>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,36 @@
# license that can be found in the LICENSE file.

from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse

from conda_store_server._internal.server import dependencies

router_conda_store_ui = APIRouter(tags=["conda-store-ui"])
router_conda_store_ui = APIRouter(
prefix="/ui",
tags=["conda-store-ui"],
# Provide the response class so that it can show up in the autogenerated API
# docs. The docs show the endpoints as returning HTML rather than JSON.
default_response_class=HTMLResponse,
)


# Yes, "{path:path}"" makes "/" redundant. But if we don't define "/", then we
# always have to pass `path` to the `url_for` function like so:
# url_for("get_conda_store_ui", path="")
@router_conda_store_ui.get("/")
@router_conda_store_ui.get("{path:path}")
async def get_conda_store_ui(
request: Request,
path: str,
templates=Depends(dependencies.get_templates),
url_prefix=Depends(dependencies.get_url_prefix),
):
context = {
"request": request,
"url_prefix": url_prefix,
"ui_prefix": router_conda_store_ui.prefix,
}
return templates.TemplateResponse("conda-store-ui.html", context)
response = templates.TemplateResponse("conda-store-ui.html", context)
if path.endswith("not-found"):
response.status_code = 404
return response
104 changes: 104 additions & 0 deletions conda-store-server/tests/_internal/server/views/test_conda_store_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Copyright (c) conda-store development team. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.

import pytest
from fastapi.testclient import TestClient


def assert_client_app(response):
"""A few checks that should all pass if the response is ok and contains
the client app
"""
assert response.is_success
assert "text/html" in response.headers["content-type"]
assert "condaStoreConfig" in response.text


class TestUIRoutes:
url_prefix = "/"

def full_route(self, route):
if self.url_prefix == "/":
return route
else:
return self.url_prefix + route

@pytest.fixture
def testclient(self, conda_store_server):
conda_store_server.enable_ui = True
conda_store_server.url_prefix = self.url_prefix
return TestClient(conda_store_server.init_fastapi_app())

def test_base_route(self, testclient):
"""The server should return the client app"""
response = testclient.get(self.url_prefix)
assert_client_app(response)

def test_unknown_routes(self, testclient):
"""Rather than return a 404, the server should return the client app
and let it handle unknown routes
"""
response = testclient.get(self.full_route("/ui/foo"))
assert_client_app(response)

response = testclient.get(self.full_route("/ui/foo/bar"))
assert_client_app(response)

def test_not_found_route(self, testclient):
"""The /not-found route should also return the client app
but with a 404 status code
"""
response = testclient.get(self.full_route("/ui/not-found"))
assert response.status_code == 404
assert "text/html" in response.headers["content-type"]
assert "condaStoreConfig" in response.text

def test_route_outside_ui_app(self, testclient):
"""The server should not return the client app for a server-side
route
"""
response = testclient.get(self.full_route("/admin/"))
assert response.is_success
assert "condaStoreConfig" not in response.text

response = testclient.get(self.full_route("/favicon.ico"))
assert response.is_success
assert response.headers["content-type"].startswith("image/")


def assert_not_found_not_client_app(response):
assert response.status_code == 404
assert "condaStoreConfig" not in response.text


class TestUIRoutesCustomPrefix(TestUIRoutes):
url_prefix = "/conda-store"

def test_unknown_route_outside_prefix(self, testclient):
"""The server should return a 404 for an unknown route outside
the url prefix and should not return the client app
"""
response = testclient.get("/ui/foo/bar")
assert_not_found_not_client_app(response)


def test_ui_disabled(conda_store_server):
"""When the UI is disabled, the server should return 404s for
all UI routes and should not return the client app
"""
conda_store_server.enable_ui = False
conda_store_server.url_prefix = "/"
testclient = TestClient(conda_store_server.init_fastapi_app())

response = testclient.get("/")
assert_not_found_not_client_app(response)

response = testclient.get("/ui/not-found")
assert_not_found_not_client_app(response)

response = testclient.get("/admin/")
assert_not_found_not_client_app(response)

response = testclient.get("/favicon.ico")
assert_not_found_not_client_app(response)
2 changes: 2 additions & 0 deletions docusaurus-docs/conda-store-ui/how-tos/configure-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ REACT_APP_AUTH_TOKEN=
REACT_APP_STYLE_TYPE=green-accent
REACT_APP_SHOW_AUTH_BUTTON=true
REACT_APP_LOGOUT_PAGE_URL=http://localhost:8080/conda-store/logout?next=/
REACT_APP_URL_BASENAME="/conda-store/ui"
```

### At runtime, using `condaStoreConfig`
Expand All @@ -40,6 +41,7 @@ In your HTML file, add the following **before** loading the react app :
REACT_APP_API_URL: "http://localhost:8080/conda-store",
REACT_APP_LOGIN_PAGE_URL: "http://localhost:8080/conda-store/login?next=",
REACT_APP_LOGOUT_PAGE_URL: "http://localhost:8080/conda-store/logout?next=/",
REACT_APP_URL_BASENAME="/conda-store/ui",
};
</script>
```
Expand Down

0 comments on commit f2680f5

Please sign in to comment.