diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae713a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/QA.md b/QA.md new file mode 100644 index 0000000..2ef83bc --- /dev/null +++ b/QA.md @@ -0,0 +1,20 @@ +app_type: web-api +coverage_applies: true +coverage_source: app +coverage_threshold: 70 +coverage_tool: pytest-cov +install_steps: +- pip install -r /tmp/forge-repos/hello-world-micro-api-02986f67/requirements.txt +- pip install ruff +lint_tool: ruff check . +notes: Verify that GET /hello returns 200 with 'message' equal to 'hello world' and + a valid ISO 8601 'timestamp', and that a non-existent route returns 404. +stack: Python/FastAPI +test_files: +- tests/test_hello.py +- tests/test_main.py +- tests/test_models.py +- tests/test_storage.py +- tests/test_todos.py +test_runner: pytest tests/ -v --tb=short +workspace: /tmp/forge-repos/hello-world-micro-api-02986f67 diff --git a/RUNNING.md b/RUNNING.md index 77896cf..3c71f21 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,47 @@ -# Running the Todo API +# Hello World API -## Prerequisites +## TEAM_BRIEF +stack: Python/FastAPI +test_runner: pytest tests/ -v +lint_tool: ruff check . +coverage_tool: pytest-cov +coverage_threshold: 70 +coverage_applies: true -- Python 3.10 or later - -## Install dependencies +## Quick Start ```bash -pip install fastapi uvicorn pydantic +pip install -r requirements.txt +uvicorn app:app --host 0.0.0.0 --port 8000 ``` -For running the test suite you will also need: +Then open http://localhost:8000/hello -```bash -pip install httpx pytest -``` +## API Reference -## Start the server +### GET /hello -```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +Returns a JSON object: + +```json +{ + "message": "hello world", + "timestamp": "2024-01-01T00:00:00+00:00" +} ``` -The API will be available at . +| Field | Type | Description | +|-------------|--------|--------------------------------------| +| message | string | Always `"hello world"` | +| timestamp | string | Current UTC time in ISO 8601 format | + +### GET / -Interactive docs are served at . +Health-check endpoint returning `{"status": "ok"}`. -## Run the tests +## Running Tests ```bash -pytest tests/ +pip install -r requirements.txt +pytest tests/ -v --tb=short --cov=app --cov-report=term-missing ``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..a0073cb --- /dev/null +++ b/app.py @@ -0,0 +1,37 @@ +"""FastAPI application with a GET /hello endpoint. + +Returns a JSON payload containing a greeting message and the current +UTC timestamp in ISO 8601 format. +""" + +from __future__ import annotations + +import datetime + +from fastapi import FastAPI +from pydantic import BaseModel + + +class HelloResponse(BaseModel): + """Response model for the /hello endpoint.""" + + message: str + timestamp: str + + +app = FastAPI(title="Hello World API", version="1.0.0") + + +@app.get("/", tags=["root"]) +async def root() -> dict: + """Health-check endpoint returning a simple status object.""" + return {"status": "ok"} + + +@app.get("/hello", response_model=HelloResponse, tags=["hello"]) +async def hello() -> HelloResponse: + """Return a greeting with the current UTC timestamp.""" + return HelloResponse( + message="hello world", + timestamp=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ) diff --git a/conftest.py b/conftest.py index 2cb6af4..f62d5ec 100644 --- a/conftest.py +++ b/conftest.py @@ -7,6 +7,11 @@ import sys from pathlib import Path +# Ensure the project root is on sys.path so that 'import app' works. +_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..eee0816 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + api: + build: . + ports: + - "8000:8000" + environment: + PYTHONUNBUFFERED: "1" diff --git a/requirements.txt b/requirements.txt index 5a6ebf7..cf578d3 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[standard]>=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_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..a583160 --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,81 @@ +"""Test suite for the Hello World API. + +Covers: +- GET /hello returns 200 +- Response JSON contains 'message' key with value 'hello world' +- Response JSON contains 'timestamp' key that is a valid ISO 8601 string +- Timestamp is UTC-aware +- Response schema has exactly the expected keys +- GET / health-check returns 200 +- POST /hello returns 405 (Method Not Allowed) +- GET on a non-existent route returns 404 +""" + +from __future__ import annotations + +import datetime + +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_hello_returns_200() -> None: + """GET /hello must return HTTP 200.""" + response = client.get("/hello") + assert response.status_code == 200 + + +def test_hello_response_has_message() -> None: + """Response body must contain 'message' equal to 'hello world'.""" + response = client.get("/hello") + body = response.json() + assert "message" in body + assert body["message"] == "hello world" + + +def test_hello_response_has_iso_timestamp() -> None: + """Response body must contain 'timestamp' that is a valid ISO 8601 string.""" + response = client.get("/hello") + body = response.json() + assert "timestamp" in body + # datetime.fromisoformat will raise ValueError for non-ISO strings + parsed = datetime.datetime.fromisoformat(body["timestamp"]) + assert isinstance(parsed, datetime.datetime) + + +def test_hello_response_timestamp_is_utc() -> None: + """Parsed timestamp must be timezone-aware with UTC offset of zero.""" + response = client.get("/hello") + body = response.json() + parsed = datetime.datetime.fromisoformat(body["timestamp"]) + assert parsed.tzinfo is not None + assert parsed.utcoffset() == datetime.timedelta(0) + + +def test_hello_response_schema_keys() -> None: + """Response JSON must contain exactly 'message' and 'timestamp' keys.""" + response = client.get("/hello") + body = response.json() + assert set(body.keys()) == {"message", "timestamp"} + + +def test_root_returns_200() -> None: + """GET / health-check must return 200 with {'status': 'ok'}.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_hello_method_not_allowed() -> None: + """POST /hello must return 405 Method Not Allowed.""" + response = client.post("/hello") + assert response.status_code == 405 + + +def test_nonexistent_route_returns_404() -> None: + """GET on a route that does not exist must return 404.""" + response = client.get("/nonexistent-route") + assert response.status_code == 404