diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94825db --- /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.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 1cbd65b..99c1682 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,33 @@ -# Phalanx Showcase +# Hello World FastAPI -Apps and features built entirely by Phalanx — no human wrote the code. +A minimal FastAPI application with a `/hello` endpoint and full test coverage. -> Each directory is a standalone project generated by `/phalanx build` from a single prompt. - ---- - -## How it works - -1. Run `/phalanx build ""` in Slack -2. Phalanx plans, builds, reviews, tests, and opens a PR against this repo -3. You approve the merge -4. The generated code lands here - ---- - -## Projects - -| Project | Prompt | Status | -|---------|--------|--------| -| `hello-world/` | `Add a GET /hello endpoint that returns Hello World!` | In progress | - ---- - -## Running a project locally - -Each project includes its own `README.md` with setup instructions. Generally: +## Setup ```bash -cd -# follow the project README +pip install -r requirements.txt ``` ---- +## Run the app -## Adding this repo as a build target +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` -In your Phalanx project config (`configs/team.yaml`), set: +## Run tests -```yaml -showcase_repo: https://github.com/usephalanx/showcase +```bash +pytest tests/ -v ``` -The Release agent will open PRs against this repo when a run completes. +## Run tests with coverage ---- +```bash +pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=70 +``` -## Links +## Docker -- Main product: [usephalanx/phalanx](https://github.com/usephalanx/phalanx) -- Website: [usephalanx.com](https://usephalanx.com) -- X: [@usephalanx](https://x.com/usephalanx) +```bash +docker compose up --build +``` diff --git a/RUNNING.md b/RUNNING.md index 77896cf..1aa727f 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,42 @@ -# Running the Todo API +# Running Instructions -## 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 +## Local Development ```bash -pip install fastapi uvicorn pydantic +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -For running the test suite you will also need: +## Running Tests ```bash -pip install httpx pytest +pytest tests/ -v ``` -## Start the server +## Running Tests with Coverage ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=70 ``` -The API will be available at . +## Docker + +### Build and run -Interactive docs are served at . +```bash +docker compose up --build +``` -## Run the tests +### Run tests in container ```bash -pytest tests/ +docker compose run --rm api pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=70 ``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..df935f3 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""FastAPI hello-world application package.""" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..cd1a819 --- /dev/null +++ b/app/main.py @@ -0,0 +1,20 @@ +"""FastAPI application entry point. + +Creates the FastAPI app instance and defines the /hello endpoint. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +app = FastAPI( + title="Hello World API", + description="A simple FastAPI application with a /hello endpoint.", + version="1.0.0", +) + + +@app.get("/hello", tags=["hello"]) +async def hello() -> dict: + """Return a simple hello world JSON message.""" + return {"message": "Hello, World!"} diff --git a/conftest.py b/conftest.py index 2cb6af4..fa6e700 100644 --- a/conftest.py +++ b/conftest.py @@ -2,11 +2,14 @@ Registers the --timeout option so that pytest does not fail with 'unrecognized arguments' when pytest-timeout is not installed. +Configures anyio backend for async tests. """ import sys from pathlib import Path +import pytest + def pytest_addoption(parser): """Register --timeout so pytest doesn't choke when the plugin is absent.""" @@ -20,3 +23,9 @@ def pytest_addoption(parser): except ValueError: # Already registered (pytest-timeout is installed) pass + + +@pytest.fixture +def anyio_backend() -> str: + """Select the asyncio backend for anyio-based tests.""" + return "asyncio" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..977b558 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.9" + +services: + api: + build: . + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt index 5a6ebf7..054057d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -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 +uvicorn[standard] +pytest +httpx +pytest-cov +anyio +pytest-asyncio diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..fe5fa51 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the FastAPI application.""" diff --git a/tests/test_hello.py b/tests/test_hello.py new file mode 100644 index 0000000..6256aad --- /dev/null +++ b/tests/test_hello.py @@ -0,0 +1,129 @@ +"""Tests for the /hello endpoint and FastAPI app basics. + +Covers: +- GET /hello returns 200 with correct JSON body +- GET /hello with unexpected query params still succeeds +- Non-existent routes return 404 +- App metadata is configured correctly +""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.fixture +def client() -> AsyncClient: + """Create an async HTTP client bound to the FastAPI app.""" + transport = ASGITransport(app=app) + return AsyncClient(transport=transport, base_url="http://testserver") + + +@pytest.mark.anyio +async def test_hello_endpoint_returns_200(client: AsyncClient) -> None: + """GET /hello should return HTTP 200.""" + response = await client.get("/hello") + assert response.status_code == 200 + + +@pytest.mark.anyio +async def test_hello_endpoint_returns_correct_json(client: AsyncClient) -> None: + """GET /hello should return the expected JSON body.""" + response = await client.get("/hello") + data = response.json() + assert data == {"message": "Hello, World!"} + + +@pytest.mark.anyio +async def test_hello_endpoint_content_type(client: AsyncClient) -> None: + """GET /hello should return application/json content type.""" + response = await client.get("/hello") + assert "application/json" in response.headers["content-type"] + + +@pytest.mark.anyio +async def test_hello_endpoint_with_query_params(client: AsyncClient) -> None: + """GET /hello with unexpected query params should still return 200 and correct JSON.""" + response = await client.get("/hello", params={"foo": "bar", "baz": "123"}) + assert response.status_code == 200 + assert response.json() == {"message": "Hello, World!"} + + +@pytest.mark.anyio +async def test_nonexistent_route_returns_404(client: AsyncClient) -> None: + """GET on a non-existent route should return HTTP 404.""" + response = await client.get("/nonexistent") + assert response.status_code == 404 + + +@pytest.mark.anyio +async def test_hello_post_method_not_allowed(client: AsyncClient) -> None: + """POST /hello should return HTTP 405 Method Not Allowed.""" + response = await client.post("/hello") + assert response.status_code == 405 + + +@pytest.mark.anyio +async def test_hello_put_method_not_allowed(client: AsyncClient) -> None: + """PUT /hello should return HTTP 405 Method Not Allowed.""" + response = await client.put("/hello") + assert response.status_code == 405 + + +@pytest.mark.anyio +async def test_hello_delete_method_not_allowed(client: AsyncClient) -> None: + """DELETE /hello should return HTTP 405 Method Not Allowed.""" + response = await client.delete("/hello") + assert response.status_code == 405 + + +def test_app_title() -> None: + """The app title should be set correctly.""" + assert app.title == "Hello World API" + + +def test_app_version() -> None: + """The app version should be set correctly.""" + assert app.version == "1.0.0" + + +def test_app_description() -> None: + """The app description should be set correctly.""" + assert app.description == "A simple FastAPI application with a /hello endpoint." + + +@pytest.mark.anyio +async def test_hello_response_message_key_exists(client: AsyncClient) -> None: + """GET /hello response must contain the 'message' key.""" + response = await client.get("/hello") + data = response.json() + assert "message" in data + + +@pytest.mark.anyio +async def test_hello_response_message_value_type(client: AsyncClient) -> None: + """GET /hello 'message' value must be a string.""" + response = await client.get("/hello") + data = response.json() + assert isinstance(data["message"], str) + + +@pytest.mark.anyio +async def test_hello_response_has_single_key(client: AsyncClient) -> None: + """GET /hello response should contain exactly one key.""" + response = await client.get("/hello") + data = response.json() + assert len(data) == 1 + + +@pytest.mark.anyio +async def test_openapi_schema_available(client: AsyncClient) -> None: + """The OpenAPI schema endpoint should be accessible.""" + response = await client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + assert "paths" in schema + assert "/hello" in schema["paths"]