diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed1633a --- /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:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/QA.md b/QA.md new file mode 100644 index 0000000..64602cc --- /dev/null +++ b/QA.md @@ -0,0 +1,19 @@ +app_type: web-api +coverage_applies: true +coverage_source: app +coverage_threshold: 70 +coverage_tool: pytest-cov +install_steps: +- pip install -r requirements.txt +lint_tool: ruff check . +notes: Verify GET /hello returns 200 with correct JSON containing 'message' and valid + ISO 8601 'timestamp', 404 for non-existent routes, and 405 for wrong HTTP methods. +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 +workspace: /tmp/forge-repos/hello-world-micro-api-c74aba96 diff --git a/RUNNING.md b/RUNNING.md index 77896cf..7de254f 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,59 @@ -# Running the Todo API +# Hello World API -## Prerequisites +A minimal FastAPI application exposing a single `GET /hello` endpoint. -- Python 3.10 or later +## TEAM_BRIEF +stack: Python/FastAPI +test_runner: pytest tests/ -v +lint_tool: ruff check . +coverage_tool: pytest-cov +coverage_threshold: 70 +coverage_applies: true -## Install dependencies +## Quick Start + +### Local + +```bash +pip install -r requirements.txt +uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +Open + +### Docker ```bash -pip install fastapi uvicorn pydantic +docker compose up --build ``` -For running the test suite you will also need: +## Run Tests ```bash -pip install httpx pytest +pip install -r requirements.txt +pytest tests/ -v ``` -## Start the server +Or via Docker: ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +docker compose run --rm api pytest tests/ -v ``` -The API will be available at . +## API Reference -Interactive docs are served at . +### GET /hello -## Run the tests +**Response** `200 OK` -```bash -pytest tests/ +```json +{ + "message": "hello world", + "timestamp": "2025-01-01T00:00:00+00:00" +} ``` + +| Field | Type | Description | +|-------------|--------|------------------------------------------| +| message | string | Always `"hello world"` | +| timestamp | string | Current UTC time in ISO 8601 format | diff --git a/app.py b/app.py new file mode 100644 index 0000000..4e2dc4d --- /dev/null +++ b/app.py @@ -0,0 +1,38 @@ +"""FastAPI application with a single GET /hello endpoint. + +Returns a JSON object 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") + + +@app.get("/hello", response_model=HelloResponse) +async def hello() -> HelloResponse: + """Return a hello world message with the current UTC timestamp.""" + now = datetime.datetime.now(datetime.timezone.utc) + return HelloResponse( + message="hello world", + timestamp=now.isoformat(), + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7fa5e30 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +services: + api: + build: . + ports: + - "8000:8000" diff --git a/requirements.txt b/requirements.txt index 5a6ebf7..cd45912 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.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +httpx==0.28.1 +pytest==8.3.4 diff --git a/tests/test_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..28d2509 --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,109 @@ +"""Comprehensive tests for the Hello World API /hello endpoint. + +Covers: +- GET /hello returns 200 +- Response JSON contains 'message' key with value 'hello world' +- Response JSON contains 'timestamp' key with a valid ISO 8601 string (UTC) +- Response body contains exactly the expected keys +- Non-existent routes return 404 +- Wrong HTTP method on /hello returns 405 +""" + +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 OK.""" + response = client.get("/hello") + assert response.status_code == 200 + + +def test_hello_response_has_message_field() -> None: + """Response JSON 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_timestamp_iso8601() -> None: + """Response JSON must contain 'timestamp' that is a valid ISO 8601 UTC string.""" + response = client.get("/hello") + body = response.json() + assert "timestamp" in body + + # Must parse without raising an exception + parsed = datetime.datetime.fromisoformat(body["timestamp"]) + + # Must carry timezone information (UTC) + assert parsed.tzinfo is not None + assert parsed.tzinfo == datetime.timezone.utc + + +def test_hello_response_keys_exact() -> 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_hello_timestamp_is_recent() -> None: + """The returned timestamp should be within a few seconds of 'now'.""" + before = datetime.datetime.now(datetime.timezone.utc) + response = client.get("/hello") + after = datetime.datetime.now(datetime.timezone.utc) + + body = response.json() + ts = datetime.datetime.fromisoformat(body["timestamp"]) + + assert before <= ts <= after + + +def test_hello_content_type_json() -> None: + """GET /hello must return a JSON content type.""" + response = client.get("/hello") + assert "application/json" in response.headers["content-type"] + + +def test_nonexistent_route_returns_404() -> None: + """A request to a route that does not exist must return 404.""" + response = client.get("/") + assert response.status_code == 404 + + +def test_nonexistent_route_random_path_returns_404() -> None: + """A request to an arbitrary undefined path must return 404.""" + response = client.get("/nonexistent") + assert response.status_code == 404 + + +def test_hello_method_not_allowed_post() -> None: + """POST /hello must return 405 Method Not Allowed.""" + response = client.post("/hello") + assert response.status_code == 405 + + +def test_hello_method_not_allowed_put() -> None: + """PUT /hello must return 405 Method Not Allowed.""" + response = client.put("/hello") + assert response.status_code == 405 + + +def test_hello_method_not_allowed_delete() -> None: + """DELETE /hello must return 405 Method Not Allowed.""" + response = client.delete("/hello") + assert response.status_code == 405 + + +def test_hello_method_not_allowed_patch() -> None: + """PATCH /hello must return 405 Method Not Allowed.""" + response = client.patch("/hello") + assert response.status_code == 405