From 380bdf83336e45fbd63b631a23083d0d9d692d71 Mon Sep 17 00:00:00 2001 From: eecavanna Date: Fri, 18 Jul 2025 17:24:26 -0700 Subject: [PATCH 1/6] Ensure value passed to `str` parameter is always a string --- src/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.py b/src/server.py index a46054e..201d5e6 100644 --- a/src/server.py +++ b/src/server.py @@ -24,7 +24,7 @@ "[View source](https://github.com/ber-data/bertron/blob/main/src/server.py)\n\n" f"[BERtron schema](https://ber-data.github.io/bertron-schema/) version: `{get_package_version('bertron-schema')}`" ), - version=get_package_version("bertron"), + version=f"{get_package_version('bertron')}", ) From 2ce1acd363ed3a7acd6fc0fcd28ba1554357ccfe Mon Sep 17 00:00:00 2001 From: eecavanna Date: Fri, 18 Jul 2025 17:37:57 -0700 Subject: [PATCH 2/6] Add `httpx` as a development dependency --- pyproject.toml | 3 +++ uv.lock | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3dbcfab..96700c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ dependencies = [ [dependency-groups] dev = [ + # `httpx` is a dependency of FastAPI's `TestClient` class. + # Docs: https://fastapi.tiangolo.com/tutorial/testing/#using-testclient + "httpx>=0.28.1", "pre-commit>=4.1.0", "pyright>=1.1.386", "pytest>=8.3.5", diff --git a/uv.lock b/uv.lock index b96765d..4a408ea 100644 --- a/uv.lock +++ b/uv.lock @@ -119,6 +119,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, @@ -138,6 +139,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "pre-commit", specifier = ">=4.1.0" }, { name = "pyright", specifier = ">=1.1.386" }, { name = "pytest", specifier = ">=8.3.5" }, @@ -439,7 +441,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ From 4572f2bcad554d461593d9b8425582dfc90cafc2 Mon Sep 17 00:00:00 2001 From: eecavanna Date: Fri, 18 Jul 2025 17:38:52 -0700 Subject: [PATCH 3/6] Add test that visits `/` and asserts that it redirects to `/docs` --- src/README.md | 1 + src/tests/test_server.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/tests/test_server.py diff --git a/src/README.md b/src/README.md index a3be001..7ae4822 100644 --- a/src/README.md +++ b/src/README.md @@ -9,3 +9,4 @@ - `lib/`: Library of helper functions, constants, etc. - `README.md`: This document - `server.py`: The BERtron API +- `tests/`: Tests targeting things implemented in this directory diff --git a/src/tests/test_server.py b/src/tests/test_server.py new file mode 100644 index 0000000..a3483ff --- /dev/null +++ b/src/tests/test_server.py @@ -0,0 +1,17 @@ +r""" +This file contains tests targeting `src/server.py`. + +You can learn about testing FastAPI apps here: +https://fastapi.tiangolo.com/tutorial/testing/ +""" + +from fastapi.testclient import TestClient +from starlette import status +from server import app + + +def test_root_endpoint_redirects_to_api_docs(): + client = TestClient(app) + response = client.get("/", follow_redirects=False) + assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT + assert response.headers["location"] == "/docs" From b5a4452085e69bdb281e36d3340d1d208436266e Mon Sep 17 00:00:00 2001 From: eecavanna Date: Fri, 18 Jul 2025 17:44:50 -0700 Subject: [PATCH 4/6] Add test that confirms `/version` returns a `VersionResponse` --- src/tests/test_server.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/tests/test_server.py b/src/tests/test_server.py index a3483ff..7074170 100644 --- a/src/tests/test_server.py +++ b/src/tests/test_server.py @@ -5,13 +5,30 @@ https://fastapi.tiangolo.com/tutorial/testing/ """ +import pytest from fastapi.testclient import TestClient from starlette import status + +from models import VersionResponse from server import app -def test_root_endpoint_redirects_to_api_docs(): - client = TestClient(app) - response = client.get("/", follow_redirects=False) +@pytest.fixture +def test_client(): + test_client = TestClient(app) + yield test_client + + +def test_root_endpoint_redirects_to_api_docs(test_client: TestClient): + response = test_client.get("/", follow_redirects=False) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT assert response.headers["location"] == "/docs" + + +def test_version_endpoint_returns_version_response(test_client: TestClient): + response = test_client.get("/version") + assert response.status_code == status.HTTP_200_OK + json_response = response.json() + expected_fields = set(VersionResponse.model_fields.keys()) + actual_fields = set(json_response.keys()) + assert actual_fields == expected_fields From 074e7ef784104068ab1898e9a061cf8e902a392c Mon Sep 17 00:00:00 2001 From: eecavanna Date: Fri, 18 Jul 2025 19:04:12 -0700 Subject: [PATCH 5/6] Leverage Pydantic model to validate shape of API response --- src/models.py | 9 ++++++++- src/tests/test_server.py | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/models.py b/src/models.py index 0ea55df..e02bb4d 100644 --- a/src/models.py +++ b/src/models.py @@ -1,9 +1,14 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from typing import Optional class HealthResponse(BaseModel): r"""A response containing system health information.""" + + # Raise a `ValidationError` if extra parameters are passed in when instantiating this class. + # Note: This facilitates having our tests confirm API responses don't include extra fields. + # Docs: https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.extra + model_config = ConfigDict(extra="forbid") web_server: bool = Field( ..., @@ -20,6 +25,8 @@ class HealthResponse(BaseModel): class VersionResponse(BaseModel): r"""A response containing system version information.""" + model_config = ConfigDict(extra="forbid") + api: Optional[str] = Field( ..., title="API version", diff --git a/src/tests/test_server.py b/src/tests/test_server.py index 7074170..1181047 100644 --- a/src/tests/test_server.py +++ b/src/tests/test_server.py @@ -28,7 +28,7 @@ def test_root_endpoint_redirects_to_api_docs(test_client: TestClient): def test_version_endpoint_returns_version_response(test_client: TestClient): response = test_client.get("/version") assert response.status_code == status.HTTP_200_OK - json_response = response.json() - expected_fields = set(VersionResponse.model_fields.keys()) - actual_fields = set(json_response.keys()) - assert actual_fields == expected_fields + # Note: This will raise a `ValidationError` if the response is not + # a valid `VersionResponse` (e.g. if it has extra fields or + # its fields' values are of an incompatible data type). + _ = VersionResponse(**response.json()) From 2ee3adc826d2ac73d4b316d0a5673a3aedfd65b8 Mon Sep 17 00:00:00 2001 From: eecavanna Date: Fri, 18 Jul 2025 19:08:51 -0700 Subject: [PATCH 6/6] Use `ruff` to format Python code (oops) --- src/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models.py b/src/models.py index e02bb4d..4770658 100644 --- a/src/models.py +++ b/src/models.py @@ -4,7 +4,7 @@ class HealthResponse(BaseModel): r"""A response containing system health information.""" - + # Raise a `ValidationError` if extra parameters are passed in when instantiating this class. # Note: This facilitates having our tests confirm API responses don't include extra fields. # Docs: https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.extra