Repository: AgnostiqHQ/covalent (language: python)

Issue:
fix ui_backend handling of webapp url paths
Loading paths other than the base URL currently returns a 404 page. Instead it should load the webapp and let it handle the route itself, e.g. /preview or /[dispatch-id].


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index dfe0f1471..28bbcd549 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -40,7 +40,7 @@ repos:
         args: ["--profile", "black"]
 
   - repo: https://github.com/ambv/black
-    rev: 22.1.0
+    rev: 22.3.0
     hooks:
       - id: black
         language_version: python3.8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42c6a1b09..5ec170cc1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+### Fixed
+
+- Fix handling of webapp url paths by ui_backend.
+
 ## [0.67.3] - 2022-04-07
 
 ### Fixed
diff --git a/refactor/results/openapi.yaml b/refactor/results/openapi.yaml
index 4314b05c3..dc4104471 100644
--- a/refactor/results/openapi.yaml
+++ b/refactor/results/openapi.yaml
@@ -14,12 +14,12 @@ paths:
         - required: false
           schema:
             allOf:
-              - $ref: '#/components/schemas/ResultFormats'
+              - $ref: "#/components/schemas/ResultFormats"
             default: json
           name: format
           in: query
       responses:
-        '200':
+        "200":
           description: Return JSON serialized result objects from the database
           content:
             application/json:
@@ -27,13 +27,13 @@ paths:
                 title: Response 200 Get Results Api V0 Workflow Results Get
                 type: array
                 items:
-                  $ref: '#/components/schemas/Result'
-        '422':
+                  $ref: "#/components/schemas/Result"
+        "422":
           description: Validation Error
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/HTTPValidationError'
+                $ref: "#/components/schemas/HTTPValidationError"
     post:
       tags:
         - Workflow
@@ -44,21 +44,21 @@ paths:
         content:
           multipart/form-data:
             schema:
-              $ref: '#/components/schemas/Body_insert_result_api_v0_workflow_results_post'
+              $ref: "#/components/schemas/Body_insert_result_api_v0_workflow_results_post"
         required: true
       responses:
-        '200':
+        "200":
           description: Successful Response
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/InsertResultResponse'
-        '422':
+                $ref: "#/components/schemas/InsertResultResponse"
+        "422":
           description: Validation Error
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/HTTPValidationError'
+                $ref: "#/components/schemas/HTTPValidationError"
     delete:
       tags:
         - Workflow
@@ -75,18 +75,18 @@ paths:
           name: dispatch_ids
           in: query
       responses:
-        '200':
+        "200":
           description: Successful Response
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/DeleteResultResponse'
-        '422':
+                $ref: "#/components/schemas/DeleteResultResponse"
+        "422":
           description: Validation Error
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/HTTPValidationError'
+                $ref: "#/components/schemas/HTTPValidationError"
   /api/v0/workflow/results/{dispatch_id}:
     get:
       tags:
@@ -104,7 +104,7 @@ paths:
         - required: false
           schema:
             allOf:
-              - $ref: '#/components/schemas/ResultFormats'
+              - $ref: "#/components/schemas/ResultFormats"
             default: binary
           name: format
           in: query
@@ -142,7 +142,7 @@ paths:
         content:
           multipart/form-data:
             schema:
-              $ref: '#/components/schemas/Body_update_result_api_v0_workflow_results__dispatch_id__put'
+              $ref: "#/components/schemas/Body_update_result_api_v0_workflow_results__dispatch_id__put"
         required: true
       responses:
         "200":
@@ -162,7 +162,7 @@ paths:
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/HTTPValidationError'
+                $ref: "#/components/schemas/HTTPValidationError"
 components:
   schemas:
     Body_insert_result_api_v0_workflow_results_post:
@@ -213,12 +213,12 @@ components:
           title: Nodes
           type: array
           items:
-            $ref: '#/components/schemas/Node'
+            $ref: "#/components/schemas/Node"
         links:
           title: Links
           type: array
           items:
-            $ref: '#/components/schemas/Link'
+            $ref: "#/components/schemas/Link"
     HTTPExceptionSchema:
       title: HTTPExceptionSchema
       required:
@@ -326,7 +326,7 @@ components:
           title: Status
           type: string
         graph:
-          $ref: '#/components/schemas/Graph'
+          $ref: "#/components/schemas/Graph"
     ResultFormats:
       title: ResultFormats
       enum:
diff --git a/refactor/ui_backend/app/api/api_v0/endpoints/ui.py b/refactor/ui_backend/app/api/api_v0/endpoints/ui.py
index c84fce635..8e88b439e 100644
--- a/refactor/ui_backend/app/api/api_v0/endpoints/ui.py
+++ b/refactor/ui_backend/app/api/api_v0/endpoints/ui.py
@@ -40,8 +40,12 @@ async def throttle_request_update_notify(app, dispatch_id, task_id):
         dispatch_set.add((dispatch_id, task_id))
         async with limiter:
             dispatch_set.remove((dispatch_id, task_id))
-            logging.debug(f"Emitting websocket event to update task {task_id} in workflow {dispatch_id}")
-            await notify_frontend(app, "result-update", {"result": {"dispatch_id": dispatch_id, "task_id": task_id}})
+            logging.debug(
+                f"Emitting websocket event to update task {task_id} in workflow {dispatch_id}"
+            )
+            await notify_frontend(
+                app, "result-update", {"result": {"dispatch_id": dispatch_id, "task_id": task_id}}
+            )
 
 
 @router.put(
diff --git a/refactor/ui_backend/main.py b/refactor/ui_backend/main.py
index e6ed224cc..949b37b6b 100644
--- a/refactor/ui_backend/main.py
+++ b/refactor/ui_backend/main.py
@@ -29,10 +29,29 @@
 from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi_socketio import SocketManager
+from starlette.exceptions import HTTPException
 
 BASE_PATH = Path(__file__).resolve().parent
 FRONTEND_PATH = "/webapp/build"
 
+
+class SinglePageApp(StaticFiles):
+    """
+    Allow URL paths (e.g. /preview or /[dispatch_id]) to be handled by the web
+    app by overriding the handling of 404 exceptions and passing control to the
+    app instead (/index.html)
+    """
+
+    async def get_response(self, path: str, scope):
+        try:
+            response = await super().get_response(path, scope)
+        except HTTPException as e:
+            if e.status_code == 404:
+                # return /index.html
+                response = await super().get_response(".", scope)
+        return response
+
+
 app = FastAPI(title="Covalent UI Backend Service API")
 
 sio = SocketManager(app=app)
@@ -40,7 +59,8 @@
 logging.basicConfig(level=logging.DEBUG)
 
 app.include_router(api_router, prefix=settings.API_V0_STR)
-app.mount("/", StaticFiles(directory=f"{BASE_PATH}{FRONTEND_PATH}", html=True), name="static")
+
+app.mount("/", SinglePageApp(directory=f"{BASE_PATH}{FRONTEND_PATH}", html=True), name="static")
 
 app.add_middleware(
     CORSMiddleware,


Test patch:
diff --git a/refactor/ui_backend/tests/app/api/api_v0/endpoints/ui_test.py b/refactor/ui_backend/tests/app/api/api_v0/endpoints/ui_test.py
index b428b9c60..216b6e171 100644
--- a/refactor/ui_backend/tests/app/api/api_v0/endpoints/ui_test.py
+++ b/refactor/ui_backend/tests/app/api/api_v0/endpoints/ui_test.py
@@ -2,69 +2,74 @@
 import os
 from time import sleep
 from unittest.mock import MagicMock, Mock
+
+import pytest
 from aiolimiter import AsyncLimiter
+from app.api.api_v0.endpoints.ui import dispatch_set, throttle_request_update_notify
 from fastapi import BackgroundTasks
-import pytest
-from app.api.api_v0.endpoints.ui import throttle_request_update_notify, dispatch_set
+
 
 class AsyncMock(MagicMock):
     async def __call__(self, *args, **kwargs):
         return super(AsyncMock, self).__call__(*args, **kwargs)
 
 
-MOCK_DISPATCH_ID = 'e7829'
+MOCK_DISPATCH_ID = "e7829"
 MOCK_TASK_ID = 123
 
+
 @pytest.mark.asyncio
 async def test_throttle_request_update(test_app, mocker, monkeypatch):
 
     socketio_obj_mock = AsyncMock()
-    limiter_one_per_second = AsyncLimiter(1,2) # enforce throttle of 1 call every 2 seconds
+    limiter_one_per_second = AsyncLimiter(1, 2)  # enforce throttle of 1 call every 2 seconds
+
+    monkeypatch.setattr("app.api.api_v0.endpoints.ui.limiter", limiter_one_per_second)
+    monkeypatch.setattr(test_app, "sio", socketio_obj_mock)
 
-    monkeypatch.setattr("app.api.api_v0.endpoints.ui.limiter",limiter_one_per_second)
-    monkeypatch.setattr(test_app,"sio",socketio_obj_mock)
-    
     tasks = []
 
     # should only call on edges 1st and last time as all calls within 2 second window
     # and deduping calls with same dispatch, task id pairs
-    for i in range(0,25):
-        tasks.append(asyncio.create_task(throttle_request_update_notify(test_app, MOCK_DISPATCH_ID, MOCK_TASK_ID)))
+    for i in range(0, 25):
+        tasks.append(
+            asyncio.create_task(
+                throttle_request_update_notify(test_app, MOCK_DISPATCH_ID, MOCK_TASK_ID)
+            )
+        )
 
     await asyncio.gather(*tasks)
-    assert socketio_obj_mock.emit.call_count == 2 
-   
+    assert socketio_obj_mock.emit.call_count == 2
 
 
 def test_ui_update_endpoint(test_app, mocker):
 
-    
-    
     spy = mocker.spy(BackgroundTasks, "add_task")
 
-    response = test_app.put(f"/api/v0/ui/workflow/{MOCK_DISPATCH_ID}/task/{MOCK_TASK_ID}",)
+    response = test_app.put(
+        f"/api/v0/ui/workflow/{MOCK_DISPATCH_ID}/task/{MOCK_TASK_ID}",
+    )
     assert response.status_code == 200
 
     spy.assert_called_with(
-        mocker.ANY, # self
-        throttle_request_update_notify, # add_to_bucket
-        mocker.ANY, # app
+        mocker.ANY,  # self
+        throttle_request_update_notify,  # add_to_bucket
+        mocker.ANY,  # app
         MOCK_DISPATCH_ID,
-        MOCK_TASK_ID
+        MOCK_TASK_ID,
     )
 
+
 @pytest.mark.asyncio
 async def test_draft_endpoint(test_app, mocker, monkeypatch):
 
-    #socketio_obj_mock = AsyncMock()
-    #monkeypatch.setattr(test_app,"sio",socketio_obj_mock)
+    # socketio_obj_mock = AsyncMock()
+    # monkeypatch.setattr(test_app,"sio",socketio_obj_mock)
     notify_frontend = mocker.patch("app.api.api_v0.endpoints.ui.notify_frontend")
 
-    REQUEST_BODY = {
-        "payload": {}
-    }
-    
-    response = test_app.post(f"/api/v0/ui/workflow/draft", json=REQUEST_BODY)
+    REQUEST_BODY = {"payload": {}}
+
+    response = test_app.post("/api/v0/ui/workflow/draft", json=REQUEST_BODY)
     assert response.status_code == 200
 
-    notify_frontend.assert_called_with(mocker.ANY,"draw-request",REQUEST_BODY)
\ No newline at end of file
+    notify_frontend.assert_called_with(mocker.ANY, "draw-request", REQUEST_BODY)
diff --git a/refactor/ui_backend/tests/conftest.py b/refactor/ui_backend/tests/conftest.py
index df0583154..24881ac80 100644
--- a/refactor/ui_backend/tests/conftest.py
+++ b/refactor/ui_backend/tests/conftest.py
@@ -1,7 +1,7 @@
 import pytest
+from fastapi_socketio import SocketManager
 from main import app
 from starlette.testclient import TestClient
-from fastapi_socketio import SocketManager
 
 
 @pytest.fixture(scope="module")
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 6a0a3d926..9ee56d9b8 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -5,6 +5,6 @@ nbconvert==6.3.0
 pennylane==0.16.0
 pre-commit==2.13.0
 pytest==6.2.5
+pytest-asyncio==0.18.3
 pytest-cov==2.12.0
 pytest-mock==3.6.1
-pytest-asyncio==0.18.3