diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d73e71a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /code + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/QA.md b/QA.md new file mode 100644 index 0000000..84d97ba --- /dev/null +++ b/QA.md @@ -0,0 +1,22 @@ +app_type: web-api +coverage_applies: true +coverage_source: app +coverage_threshold: 70 +coverage_tool: pytest-cov +install_steps: +- pip install --upgrade pip +- pip install -r /tmp/forge-repos/hello-world-fastapi-1ca57c11/requirements.txt +lint_tool: ruff check . +notes: Verify that all tests in tests/ pass, code coverage for app/ is at least 70%, + and ruff reports no lint errors. +stack: Python/FastAPI +test_files: +- tests/__init__.py +- tests/conftest.py +- tests/test_hello.py +- tests/test_main.py +- tests/test_models.py +- tests/test_storage.py +- tests/test_todos.py +test_runner: pytest tests/ +workspace: /tmp/forge-repos/hello-world-fastapi-1ca57c11 diff --git a/RUNNING.md b/RUNNING.md index 77896cf..b5fdd9c 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,88 @@ -# Running the Todo API +# Hello World FastAPI 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.11+ (for local development) +- Docker and Docker Compose (for containerised execution) + +## Local Development -## Install dependencies +### 1. Install dependencies ```bash -pip install fastapi uvicorn pydantic +pip install -r requirements.txt ``` -For running the test suite you will also need: +### 2. Run the application ```bash -pip install httpx pytest +uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -## Start the server +The API will be available at: **http://localhost:8000/hello** + +### 3. Run the test suite ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +pytest tests/ ``` -The API will be available at . +To run with verbose output: + +```bash +pytest tests/ -v +``` -Interactive docs are served at . +## Docker -## Run the tests +### Build and run with Docker Compose ```bash -pytest tests/ +docker compose up --build +``` + +The application will be accessible at: **http://localhost:8000/hello** + +To stop the application: + +```bash +docker compose down +``` + +### Build and run with Docker only + +```bash +docker build -t hello-api . +docker run -p 8000:8000 hello-api +``` + +## API Endpoints + +| Method | Path | Description | Response | +|--------|----------|--------------------------------------|-----------------------------------| +| GET | `/hello` | Returns a JSON greeting message | `{"message": "Hello, World!"}` | + +## Project Structure + +``` +. +├── app/ +│ ├── __init__.py +│ └── main.py # FastAPI application with /hello endpoint +├── tests/ +│ ├── __init__.py +│ └── test_hello.py # Comprehensive test suite +├── conftest.py # Root pytest configuration +├── requirements.txt # Python dependencies +├── Dockerfile # Container image definition +├── docker-compose.yml # Docker Compose orchestration +└── RUNNING.md # This file ``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..cd05692 --- /dev/null +++ b/app/main.py @@ -0,0 +1,24 @@ +"""FastAPI application entry point. + +Creates the FastAPI app instance and defines the GET /hello endpoint. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +app = FastAPI( + title="Hello World API", + description="A minimal FastAPI application with a /hello endpoint.", + version="1.0.0", +) + + +@app.get("/hello", tags=["hello"]) +async def hello() -> dict: + """Return a JSON greeting message. + + Returns: + A dictionary with a single 'message' key. + """ + return {"message": "Hello, World!"} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e6831fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.9" + +services: + web: + build: . + ports: + - "8000:8000" + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt index 5a6ebf7..9490417 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ -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 +uvicorn>=0.29.0 +pytest>=8.0.0 +httpx>=0.27.0 diff --git a/tests/test_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..15d46a5 --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,100 @@ +"""Comprehensive test suite for the /hello endpoint. + +Verifies correct behaviour of the FastAPI application's GET /hello +endpoint, including status codes, response payloads, headers, and +error handling for unsupported methods and non-existent routes. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + +client: TestClient = TestClient(app) + + +class TestHelloEndpoint: + """Tests for the GET /hello endpoint.""" + + def test_hello_returns_200(self) -> None: + """GET /hello should return HTTP 200 status code.""" + response = client.get("/hello") + assert response.status_code == 200 + + def test_hello_returns_correct_json(self) -> 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) -> None: + """GET /hello should return a JSON content-type header.""" + response = client.get("/hello") + assert "application/json" in response.headers["content-type"] + + def test_hello_message_key_present(self) -> None: + """GET /hello response must contain the 'message' key.""" + response = client.get("/hello") + data = response.json() + assert "message" in data + + def test_hello_message_value(self) -> None: + """GET /hello 'message' value must be exactly 'Hello, World!'.""" + response = client.get("/hello") + data = response.json() + assert data["message"] == "Hello, World!" + + def test_hello_response_has_single_key(self) -> None: + """GET /hello response should contain exactly one key.""" + response = client.get("/hello") + data = response.json() + assert len(data) == 1 + + +class TestHelloMethodNotAllowed: + """Tests verifying that unsupported HTTP methods return 405.""" + + def test_post_hello_returns_405(self) -> None: + """POST /hello should return HTTP 405 Method Not Allowed.""" + response = client.post("/hello") + assert response.status_code == 405 + + def test_put_hello_returns_405(self) -> None: + """PUT /hello should return HTTP 405 Method Not Allowed.""" + response = client.put("/hello") + assert response.status_code == 405 + + def test_delete_hello_returns_405(self) -> None: + """DELETE /hello should return HTTP 405 Method Not Allowed.""" + response = client.delete("/hello") + assert response.status_code == 405 + + def test_patch_hello_returns_405(self) -> None: + """PATCH /hello should return HTTP 405 Method Not Allowed.""" + response = client.patch("/hello") + assert response.status_code == 405 + + +class TestNonExistentRoutes: + """Tests verifying that requests to unknown paths return 404.""" + + def test_unknown_path_returns_404(self) -> None: + """GET /nonexistent should return HTTP 404 Not Found.""" + response = client.get("/nonexistent") + assert response.status_code == 404 + + def test_root_path_returns_404(self) -> None: + """GET / should return HTTP 404 when no root route is defined.""" + response = client.get("/") + assert response.status_code == 404 + + +class TestHelloIdempotency: + """Tests verifying that repeated calls return consistent results.""" + + def test_multiple_calls_return_same_result(self) -> None: + """Consecutive GET /hello calls should return identical responses.""" + response1 = client.get("/hello") + response2 = client.get("/hello") + assert response1.json() == response2.json() + assert response1.status_code == response2.status_code