diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..385ccd3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/RUNNING.md b/RUNNING.md index 77896cf..aab59e4 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,48 @@ -# Running the Todo API +# Running the Application + +## TEAM_BRIEF +stack: Python/FastAPI +test_runner: pytest tests/ +lint_tool: ruff check . +coverage_tool: pytest-cov +coverage_threshold: 70 +coverage_applies: true ## Prerequisites -- Python 3.10 or later +- Python 3.12+ (or Docker) +- pip -## Install dependencies +## Local Development ```bash -pip install fastapi uvicorn pydantic -``` +# Install dependencies +pip install -r requirements.txt -For running the test suite you will also need: +# Run the application +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload -```bash -pip install httpx pytest +# Run the test suite +pytest tests/ -v + +# Run tests with coverage +pytest tests/ -v --cov=app --cov-report=term-missing ``` -## Start the server +## Docker ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 -``` +# Build and start +docker compose up --build -The API will be available at . - -Interactive docs are served at . +# The API is available at: +# http://localhost:8000/hello +# http://localhost:8000/health +``` -## Run the tests +## Endpoints -```bash -pytest tests/ -``` +| Method | Path | Response | +|--------|-----------|---------------------------------| +| GET | `/hello` | `{"message": "hello world"}` | +| GET | `/health` | `{"status": "ok"}` | diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..7928ce5 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,16 @@ +"""Health check endpoint router. + +Provides a simple GET /health endpoint that returns the service status. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health", tags=["health"]) +async def health() -> dict: + """Return the current health status of the service.""" + return {"status": "ok"} diff --git a/app/api/hello.py b/app/api/hello.py new file mode 100644 index 0000000..900abc0 --- /dev/null +++ b/app/api/hello.py @@ -0,0 +1,16 @@ +"""Hello endpoint router. + +Provides a simple GET /hello endpoint that returns a greeting message. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/hello", tags=["hello"]) +async def hello() -> dict: + """Return a hello world greeting message.""" + return {"message": "hello world"} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..66ddd1c --- /dev/null +++ b/app/main.py @@ -0,0 +1,20 @@ +"""FastAPI application entry point. + +Creates the FastAPI app instance and includes the hello and health routers. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from app.api.hello import router as hello_router +from app.api.health import router as health_router + +app = FastAPI( + title="Hello & Health API", + description="A minimal FastAPI app with /hello and /health endpoints.", + version="1.0.0", +) + +app.include_router(hello_router) +app.include_router(health_router) diff --git a/conftest.py b/conftest.py index 2cb6af4..ab16b3f 100644 --- a/conftest.py +++ b/conftest.py @@ -2,11 +2,17 @@ Registers the --timeout option so that pytest does not fail with 'unrecognized arguments' when pytest-timeout is not installed. +Adds the project root to sys.path for proper module resolution. """ import sys from pathlib import Path +# Ensure the project root is on sys.path so that 'app' package is importable. +_PROJECT_ROOT = str(Path(__file__).resolve().parent) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + def pytest_addoption(parser): """Register --timeout so pytest doesn't choke when the plugin is absent.""" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fa724ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.9" + +services: + app: + build: . + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 diff --git a/requirements.txt b/requirements.txt index 5a6ebf7..95e453d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -fastapi>=0.100.0 -uvicorn>=0.23.0 -pydantic>=2.0.0 -pytest>=7.0.0 -pytest-timeout>=2.1.0 -httpx>=0.24.0 +fastapi>=0.110.0,<1.0.0 +uvicorn>=0.29.0,<1.0.0 +httpx>=0.27.0,<1.0.0 +pytest>=8.0.0,<9.0.0 +pytest-cov>=5.0.0,<6.0.0 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..abf6611 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,73 @@ +"""Tests for the /health endpoint. + +Covers happy-path responses, correct content type, method restrictions, +and behaviour when extra query parameters are supplied. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture() +def client() -> TestClient: + """Return a TestClient wired to the FastAPI application.""" + return TestClient(app) + + +class TestHealthEndpoint: + """Test suite for GET /health.""" + + def test_health_returns_200(self, client: TestClient) -> None: + """GET /health should return HTTP 200.""" + response = client.get("/health") + assert response.status_code == 200 + + def test_health_returns_correct_json(self, client: TestClient) -> None: + """GET /health should return {"status": "ok"}.""" + response = client.get("/health") + assert response.json() == {"status": "ok"} + + def test_health_content_type_is_json(self, client: TestClient) -> None: + """Response Content-Type must be application/json.""" + response = client.get("/health") + assert "application/json" in response.headers["content-type"] + + def test_health_with_extra_query_params(self, client: TestClient) -> None: + """Extra query parameters should be ignored; response stays the same.""" + response = client.get("/health", params={"debug": "true"}) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + def test_health_post_not_allowed(self, client: TestClient) -> None: + """POST /health should return 405 Method Not Allowed.""" + response = client.post("/health") + assert response.status_code == 405 + + def test_health_put_not_allowed(self, client: TestClient) -> None: + """PUT /health should return 405 Method Not Allowed.""" + response = client.put("/health") + assert response.status_code == 405 + + def test_health_delete_not_allowed(self, client: TestClient) -> None: + """DELETE /health should return 405 Method Not Allowed.""" + response = client.delete("/health") + assert response.status_code == 405 + + def test_health_patch_not_allowed(self, client: TestClient) -> None: + """PATCH /health should return 405 Method Not Allowed.""" + response = client.patch("/health") + assert response.status_code == 405 + + def test_health_response_has_status_key(self, client: TestClient) -> None: + """The JSON body must contain exactly the 'status' key.""" + data = client.get("/health").json() + assert list(data.keys()) == ["status"] + + def test_health_status_value_is_ok(self, client: TestClient) -> None: + """The 'status' value must be the string 'ok'.""" + data = client.get("/health").json() + assert data["status"] == "ok" diff --git a/tests/test_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..ff2db83 --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,73 @@ +"""Tests for the /hello endpoint. + +Covers happy-path responses, correct content type, method restrictions, +and behaviour when extra query parameters are supplied. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture() +def client() -> TestClient: + """Return a TestClient wired to the FastAPI application.""" + return TestClient(app) + + +class TestHelloEndpoint: + """Test suite for GET /hello.""" + + def test_hello_returns_200(self, client: TestClient) -> None: + """GET /hello should return HTTP 200.""" + response = client.get("/hello") + assert response.status_code == 200 + + def test_hello_returns_correct_json(self, client: TestClient) -> None: + """GET /hello should return {"message": "hello world"}.""" + response = client.get("/hello") + assert response.json() == {"message": "hello world"} + + def test_hello_content_type_is_json(self, client: TestClient) -> None: + """Response Content-Type must be application/json.""" + response = client.get("/hello") + assert "application/json" in response.headers["content-type"] + + def test_hello_with_extra_query_params(self, client: TestClient) -> None: + """Extra query parameters should be ignored; response stays the same.""" + response = client.get("/hello", params={"foo": "bar", "baz": "123"}) + assert response.status_code == 200 + assert response.json() == {"message": "hello world"} + + def test_hello_post_not_allowed(self, client: TestClient) -> None: + """POST /hello should return 405 Method Not Allowed.""" + response = client.post("/hello") + assert response.status_code == 405 + + def test_hello_put_not_allowed(self, client: TestClient) -> None: + """PUT /hello should return 405 Method Not Allowed.""" + response = client.put("/hello") + assert response.status_code == 405 + + def test_hello_delete_not_allowed(self, client: TestClient) -> None: + """DELETE /hello should return 405 Method Not Allowed.""" + response = client.delete("/hello") + assert response.status_code == 405 + + def test_hello_patch_not_allowed(self, client: TestClient) -> None: + """PATCH /hello should return 405 Method Not Allowed.""" + response = client.patch("/hello") + assert response.status_code == 405 + + def test_hello_response_has_message_key(self, client: TestClient) -> None: + """The JSON body must contain exactly the 'message' key.""" + data = client.get("/hello").json() + assert list(data.keys()) == ["message"] + + def test_hello_message_value_type_is_string(self, client: TestClient) -> None: + """The 'message' value must be a string.""" + data = client.get("/hello").json() + assert isinstance(data["message"], str)