Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(standalone): fix api path when embedding front-end #2136

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
branches:
- "master"
- "hotfix/**"
- "feature/1724-upgrade-python-dependencies"

jobs:
binary:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
pip install -r requirements-dev.txt
- name: Test with pytest
run: |
pytest --cov antarest --cov-report xml
pytest --cov antarest --cov-report xml -n auto
- name: Archive code coverage results
if: matrix.os == 'ubuntu-20.04'
uses: actions/upload-artifact@v4
Expand Down
36 changes: 36 additions & 0 deletions antarest/core/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from dataclasses import dataclass
from typing import Optional

from fastapi import APIRouter, FastAPI


@dataclass(frozen=True)
class AppBuildContext:
"""
Base elements of the application, for use at construction time:
- app: the actual fastapi application, where middlewares, exception handlers, etc. may be added
- api_root: the route under which all API and WS endpoints must be registered

API routes should not be added straight to app, but under api_root instead,
so that they are correctly prefixed if needed (/api for standalone mode).

Warning: the inclusion of api_root must happen AFTER all subroutes
have been registered, hence the build method.
"""

app: FastAPI
api_root: APIRouter

def build(self) -> FastAPI:
"""
Finalizes the app construction by including the API route.
Must be performed AFTER all subroutes have been added.
"""
self.app.include_router(self.api_root)
return self.app


def create_app_ctxt(app: FastAPI, api_root: Optional[APIRouter] = None) -> AppBuildContext:
if not api_root:
api_root = APIRouter()
return AppBuildContext(app, api_root)
2 changes: 2 additions & 0 deletions antarest/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,7 @@ class Config:
cache: CacheConfig = CacheConfig()
tasks: TaskConfig = TaskConfig()
root_path: str = ""
api_prefix: str = ""

@classmethod
def from_dict(cls, data: JSON) -> "Config":
Expand All @@ -611,6 +612,7 @@ def from_dict(cls, data: JSON) -> "Config":
cache=CacheConfig.from_dict(data["cache"]) if "cache" in data else defaults.cache,
tasks=TaskConfig.from_dict(data["tasks"]) if "tasks" in data else defaults.tasks,
root_path=data.get("root_path", defaults.root_path),
api_prefix=data.get("api_prefix", defaults.api_prefix),
)

@classmethod
Expand Down
9 changes: 5 additions & 4 deletions antarest/core/filetransfer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

from typing import Optional

from fastapi import FastAPI
from fastapi import APIRouter, FastAPI

from antarest.core.application import AppBuildContext
from antarest.core.config import Config
from antarest.core.filetransfer.repository import FileDownloadRepository
from antarest.core.filetransfer.service import FileTransferManager
Expand All @@ -22,10 +23,10 @@


def build_filetransfer_service(
application: Optional[FastAPI], event_bus: IEventBus, config: Config
app_ctxt: Optional[AppBuildContext], event_bus: IEventBus, config: Config
) -> FileTransferManager:
ftm = FileTransferManager(repository=FileDownloadRepository(), event_bus=event_bus, config=config)

if application:
application.include_router(create_file_transfer_api(ftm, config))
if app_ctxt:
app_ctxt.api_root.include_router(create_file_transfer_api(ftm, config))
return ftm
9 changes: 5 additions & 4 deletions antarest/core/maintenance/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

from typing import Optional

from fastapi import FastAPI
from fastapi import APIRouter, FastAPI

from antarest.core.application import AppBuildContext
from antarest.core.config import Config
from antarest.core.interfaces.cache import ICache
from antarest.core.interfaces.eventbus import DummyEventBusService, IEventBus
Expand All @@ -23,15 +24,15 @@


def build_maintenance_manager(
application: Optional[FastAPI],
app_ctxt: Optional[AppBuildContext],
config: Config,
cache: ICache,
event_bus: IEventBus = DummyEventBusService(),
) -> MaintenanceService:
repository = MaintenanceRepository()
service = MaintenanceService(config, repository, event_bus, cache)

if application:
application.include_router(create_maintenance_api(service, config))
if app_ctxt:
app_ctxt.api_root.include_router(create_maintenance_api(service, config))

return service
9 changes: 5 additions & 4 deletions antarest/core/tasks/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

from typing import Optional

from fastapi import FastAPI
from fastapi import APIRouter, FastAPI

from antarest.core.application import AppBuildContext
from antarest.core.config import Config
from antarest.core.interfaces.eventbus import DummyEventBusService, IEventBus
from antarest.core.tasks.repository import TaskJobRepository
Expand All @@ -22,14 +23,14 @@


def build_taskjob_manager(
application: Optional[FastAPI],
app_ctxt: Optional[AppBuildContext],
config: Config,
event_bus: IEventBus = DummyEventBusService(),
) -> ITaskService:
repository = TaskJobRepository()
service = TaskJobService(config, repository, event_bus)

if application:
application.include_router(create_tasks_api(service, config))
if app_ctxt:
app_ctxt.api_root.include_router(create_tasks_api(service, config))

return service
9 changes: 5 additions & 4 deletions antarest/eventbus/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

from typing import Optional

from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from redis import Redis

from antarest.core.application import AppBuildContext
from antarest.core.config import Config
from antarest.eventbus.business.local_eventbus import LocalEventBus
from antarest.eventbus.business.redis_eventbus import RedisEventBus
Expand All @@ -23,7 +24,7 @@


def build_eventbus(
application: Optional[FastAPI],
app_ctxt: Optional[AppBuildContext],
config: Config,
autostart: bool = True,
redis_client: Optional[Redis] = None, # type: ignore
Expand All @@ -33,6 +34,6 @@ def build_eventbus(
autostart,
)

if application:
configure_websockets(application, config, eventbus)
if app_ctxt:
configure_websockets(app_ctxt, config, eventbus)
return eventbus
7 changes: 4 additions & 3 deletions antarest/eventbus/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
from http import HTTPStatus
from typing import List, Optional

from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query
from pydantic import BaseModel
from starlette.websockets import WebSocket, WebSocketDisconnect

from antarest.core.application import AppBuildContext
from antarest.core.config import Config
from antarest.core.interfaces.eventbus import Event, IEventBus
from antarest.core.jwt import DEFAULT_ADMIN_USER, JWTUser
Expand Down Expand Up @@ -91,7 +92,7 @@ async def broadcast(self, message: str, permissions: PermissionInfo, channel: st
await connection.websocket.send_text(message)


def configure_websockets(application: FastAPI, config: Config, event_bus: IEventBus) -> None:
def configure_websockets(app_ctxt: AppBuildContext, config: Config, event_bus: IEventBus) -> None:
manager = ConnectionManager()

async def send_event_to_ws(event: Event) -> None:
Expand All @@ -100,7 +101,7 @@ async def send_event_to_ws(event: Event) -> None:
del event_data["channel"]
await manager.broadcast(json.dumps(event_data), event.permissions, event.channel)

@application.websocket("/ws")
@app_ctxt.api_root.websocket("/ws")
async def connect(
websocket: WebSocket,
token: str = Query(...),
Expand Down
139 changes: 139 additions & 0 deletions antarest/front.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Copyright (c) 2024, RTE (https://www.rte-france.com)
#
# See AUTHORS.txt
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# SPDX-License-Identifier: MPL-2.0
#
# This file is part of the Antares project.
"""
This module contains the logic necessary to serve both
the front-end application and the backend HTTP application.

This includes:
- serving static frontend files
- redirecting "not found" requests to home, which itself redirects to index.html
- providing the endpoint /config.json, which the front-end uses to know
what are the API and websocket prefixes
"""

import re
from pathlib import Path
from typing import Any, Optional, Sequence

from fastapi import FastAPI
from pydantic import BaseModel
from starlette.middleware.base import BaseHTTPMiddleware, DispatchFunction, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from starlette.types import ASGIApp

from antarest.core.utils.string import to_camel_case


class RedirectMiddleware(BaseHTTPMiddleware):
"""
Middleware that rewrites the URL path to "/" for incoming requests
that do not match the known end points. This is useful for redirecting requests
to the main page of a ReactJS application when the user refreshes the browser.
"""

def __init__(
self,
app: ASGIApp,
dispatch: Optional[DispatchFunction] = None,
route_paths: Sequence[str] = (),
) -> None:
"""
Initializes an instance of the URLRewriterMiddleware.

Args:
app: The ASGI application to which the middleware is applied.
dispatch: The dispatch function to use.
route_paths: The known route paths of the application.
Requests that do not match any of these paths will be rewritten to the root path.

Note:
The `route_paths` should contain all the known endpoints of the application.
"""
dispatch = self.dispatch if dispatch is None else dispatch
super().__init__(app, dispatch)
self.known_prefixes = {re.findall(r"/(?:(?!/).)*", p)[0] for p in route_paths if p != "/"}

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Any:
"""
Intercepts the incoming request and rewrites the URL path if necessary.
Passes the modified or original request to the next middleware or endpoint handler.
"""
url_path = request.scope["path"]
if url_path in {"", "/"}:
pass
elif not any(url_path.startswith(ep) for ep in self.known_prefixes):
request.scope["path"] = "/"
return await call_next(request)


class BackEndConfig(BaseModel):
"""
Configuration about backend URLs served to the frontend.
"""

rest_endpoint: str
ws_endpoint: str

class Config:
populate_by_name = True
alias_generator = to_camel_case


def create_backend_config(api_prefix: str) -> BackEndConfig:
if not api_prefix.startswith("/"):
api_prefix = "/" + api_prefix
return BackEndConfig(rest_endpoint=f"{api_prefix}", ws_endpoint=f"{api_prefix}/ws")


def add_front_app(application: FastAPI, resources_dir: Path, api_prefix: str) -> None:
"""
This functions adds the logic necessary to serve both
the front-end application and the backend HTTP application.

This includes:
- serving static frontend files
- redirecting "not found" requests to home, which itself redirects to index.html
- providing the endpoint /config.json, which the front-end uses to know
what are the API and websocket prefixes
"""
backend_config = create_backend_config(api_prefix)

front_app_dir = resources_dir / "webapp"

# Serve front-end files
application.mount(
"/static",
StaticFiles(directory=front_app_dir),
name="static",
)

# Redirect home to index.html
@application.get("/", include_in_schema=False)
def home(request: Request) -> Any:
return FileResponse(front_app_dir / "index.html", 200)

# Serve config for the front-end at /config.json
@application.get("/config.json", include_in_schema=False)
def get_api_paths_config(request: Request) -> BackEndConfig:
return backend_config

# When the web application is running in Desktop mode, the ReactJS web app
# is served at the `/static` entry point. Any requests that are not API
# requests should be redirected to the `index.html` file, which will handle
# the route provided by the URL.
route_paths = [r.path for r in application.routes] # type: ignore
application.add_middleware(
RedirectMiddleware,
route_paths=route_paths,
)
2 changes: 1 addition & 1 deletion antarest/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from multiprocessing import Process
from pathlib import Path

import httpx # TODO SL :check its ok on windows
import httpx
import uvicorn
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QAction, QApplication, QMenu, QSystemTrayIcon
Expand Down
9 changes: 5 additions & 4 deletions antarest/launcher/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

from typing import Optional

from fastapi import FastAPI
from fastapi import APIRouter, FastAPI

from antarest.core.application import AppBuildContext
from antarest.core.config import Config
from antarest.core.filetransfer.service import FileTransferManager
from antarest.core.interfaces.cache import ICache
Expand All @@ -26,7 +27,7 @@


def build_launcher(
application: Optional[FastAPI],
app_ctxt: Optional[AppBuildContext],
config: Config,
study_service: StudyService,
file_transfer_manager: FileTransferManager,
Expand All @@ -49,7 +50,7 @@ def build_launcher(
cache=cache,
)

if service_launcher and application:
application.include_router(create_launcher_api(service_launcher, config))
if service_launcher and app_ctxt:
app_ctxt.api_root.include_router(create_launcher_api(service_launcher, config))

return service_launcher
Loading
Loading