Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
51 changes: 33 additions & 18 deletions RUNNING.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:8000>.

Interactive docs are served at <http://localhost:8000/docs>.
# 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"}` |
Empty file added app/__init__.py
Empty file.
Empty file added app/api/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions app/api/health.py
Original file line number Diff line number Diff line change
@@ -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"}
16 changes: 16 additions & 0 deletions app/api/hello.py
Original file line number Diff line number Diff line change
@@ -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"}
20 changes: 20 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: "3.9"

services:
app:
build: .
ports:
- "8000:8000"
environment:
- PYTHONUNBUFFERED=1
11 changes: 5 additions & 6 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -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"
73 changes: 73 additions & 0 deletions tests/test_hello.py
Original file line number Diff line number Diff line change
@@ -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)