From e69405768b7c338fea444e9976b8b23c474cb61a Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Tue, 28 Jun 2022 16:46:41 +0200 Subject: [PATCH] Extract and test server configuration --- requirements.txt | 2 +- server/infrastructure/server.py | 66 +++++++++++++++++++++++++++++++++ server/main.py | 33 ++--------------- tests/api/test_config.py | 25 +++++++++++++ tests/conftest.py | 34 +++++++++++------ tests/helpers.py | 5 --- tests/test_debugging.py | 4 +- 7 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 server/infrastructure/server.py create mode 100644 tests/api/test_config.py diff --git a/requirements.txt b/requirements.txt index 97aa7448d..abc3b5415 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ fastapi==0.78.0 gunicorn==20.1.0 punq==0.6.2 pydantic[email]==1.9.0 -uvicorn[standard]==0.17.6 +-e git+https://github.com/bwhmather/uvicorn.git@server-lifecycle#egg=uvicorn [standard] sqlalchemy[asyncio,mypy]==1.4.39 # Debug diff --git a/server/infrastructure/server.py b/server/infrastructure/server.py new file mode 100644 index 000000000..cfbfa3d0b --- /dev/null +++ b/server/infrastructure/server.py @@ -0,0 +1,66 @@ +from typing import Callable, Union + +import uvicorn +import uvicorn.supervisors + +from server.config.di import resolve +from server.config.settings import Settings + + +def get_server_config( + app: Union[str, Callable], settings: Settings = None +) -> uvicorn.Config: + if settings is None: + settings = resolve(Settings) + + kwargs = dict( + host=settings.host, + port=settings.port, + ) + + if settings.server_mode == "local": + kwargs.update( + # Enable hot reload. + reload=True, + reload_dirs=["server"], + ) + elif settings.server_mode == "live": + kwargs.update( + # Pass any proxy headers, so that Uvicorn sees information about the + # connecting client, rather than the connecting Nginx proxy. + # See: https://www.uvicorn.org/deployment/#running-behind-nginx + proxy_headers=True, + # Match Nginx mount path. + root_path="/api", + ) + + return uvicorn.Config(app, **kwargs) + + +class Server(uvicorn.Server): + pass + + +def run(app: Union[str, Callable]) -> int: + """ + Run the API server. + + This is a simplified version of `uvicorn.run()`. + """ + config = get_server_config(app) + server = Server(config) + + if config.should_reload: + sock = config.bind_socket() + reloader = uvicorn.supervisors.ChangeReload( + config, target=server.run, sockets=[sock] + ) + reloader.run() + return 0 + + server.run() + + if not server.started: + return 3 + + return 0 diff --git a/server/main.py b/server/main.py index 86dc9344d..3a48fb21a 100644 --- a/server/main.py +++ b/server/main.py @@ -1,37 +1,12 @@ +import sys + from .api.app import create_app from .config.di import bootstrap +from .infrastructure.server import run bootstrap() app = create_app() if __name__ == "__main__": - import uvicorn - - from .config.di import resolve - from .config.settings import Settings - - settings = resolve(Settings) - - kwargs: dict = { - "host": settings.host, - "port": settings.port, - } - - if settings.server_mode == "local": - kwargs.update( - # Enable hot reload. - reload=True, - reload_dirs=["server"], - ) - elif settings.server_mode == "live": - kwargs.update( - # Pass any proxy headers, so that Uvicorn sees information about the - # connecting client, rather than the connecting Nginx proxy. - # See: https://www.uvicorn.org/deployment/#running-behind-nginx - proxy_headers=True, - # Match Nginx mount path. - root_path="/api", - ) - - uvicorn.run("server.main:app", **kwargs) + sys.exit(run("server.main:app")) diff --git a/tests/api/test_config.py b/tests/api/test_config.py new file mode 100644 index 000000000..d3120ff11 --- /dev/null +++ b/tests/api/test_config.py @@ -0,0 +1,25 @@ +from server.config import Settings +from server.config.di import resolve +from server.infrastructure.server import get_server_config + + +def test_server_config_local() -> None: + settings = resolve(Settings).copy(update={"server_mode": "local"}) + + config = get_server_config("server.main:app", settings) + + assert config.host == "localhost" + assert config.port == 3579 + assert config.should_reload + assert config.root_path == "" + + +def test_server_config_live() -> None: + settings = resolve(Settings).copy(update={"server_mode": "live"}) + + config = get_server_config("server.main:app", settings) + + assert config.host == "localhost" + assert config.port == 3579 + assert config.proxy_headers + assert config.root_path == "/api" diff --git a/tests/conftest.py b/tests/conftest.py index 063c0794d..d7988d891 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,12 @@ import asyncio import os -from typing import TYPE_CHECKING, AsyncIterator, Iterator +from typing import AsyncIterator, Iterator import httpx import pytest import pytest_asyncio from alembic import command from alembic.config import Config -from asgi_lifespan import LifespanManager from sqlalchemy_utils import create_database, database_exists, drop_database from server.application.datasets.queries import GetAllDatasets @@ -15,12 +14,10 @@ from server.config.di import bootstrap, resolve from server.domain.auth.entities import UserRole from server.infrastructure.database import Database +from server.infrastructure.server import Server, get_server_config from server.seedwork.application.messages import MessageBus -from .helpers import TestUser, create_client, create_test_user - -if TYPE_CHECKING: - from server.api.app import App +from .helpers import TestUser, create_test_user os.environ["APP_TESTING"] = "True" @@ -71,19 +68,34 @@ def event_loop() -> Iterator[asyncio.AbstractEventLoop]: loop.close() +class TestServer(Server): + @property + def url(self) -> str: + return f"http://{self.config.host}:{self.config.port}" + + def install_signal_handlers(self) -> None: + pass + + @pytest_asyncio.fixture(scope="session") -async def app() -> AsyncIterator["App"]: +async def server() -> AsyncIterator[TestServer]: from server.api.app import create_app app = create_app() + config = get_server_config(app) + server = TestServer(config) - async with LifespanManager(app): - yield app + await server.start_serving() + try: + yield server + finally: + server.close() + await server.wait_closed() @pytest_asyncio.fixture(scope="session") -async def client(app: "App") -> AsyncIterator[httpx.AsyncClient]: - async with create_client(app) as client: +async def client(server: TestServer) -> AsyncIterator[httpx.AsyncClient]: + async with httpx.AsyncClient(base_url=server.url) as client: yield client diff --git a/tests/helpers.py b/tests/helpers.py index 33bb1ed9d..d0eda3669 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,4 @@ import json -from typing import Callable import httpx from pydantic import BaseModel @@ -12,10 +11,6 @@ from .factories import CreateUserFactory -def create_client(app: Callable) -> httpx.AsyncClient: - return httpx.AsyncClient(app=app, base_url="http://testserver") - - def to_payload(obj: BaseModel) -> dict: """ Convert a Pydantic model instance to a JSON-serializable dictionary. diff --git a/tests/test_debugging.py b/tests/test_debugging.py index a5842637d..068cbf2ed 100644 --- a/tests/test_debugging.py +++ b/tests/test_debugging.py @@ -7,8 +7,6 @@ from server.infrastructure.database import Database from server.seedwork.application.di import Container -from .helpers import create_client - @pytest.mark.asyncio async def test_debug_default_disabled(app: App, client: httpx.AsyncClient) -> None: @@ -36,6 +34,6 @@ async def test_debug_enabled(monkeypatch: pytest.MonkeyPatch) -> None: db = container.resolve(Database) assert db.engine.echo - async with create_client(app) as client: + async with httpx.AsyncClient(app=app, base_url="http://testserver") as client: response = await client.get("/_debug_toolbar") assert response.status_code != 404