From 350143ceb43ba992dcb728791d6e768549e9e835 Mon Sep 17 00:00:00 2001 From: founder Date: Fri, 5 Jun 2026 02:45:49 +0200 Subject: [PATCH] Initial scaffold: FastAPI micro-service with Docker, SQLite, tests --- .env.example | 4 ++ .gitignore | 32 +++++++++++++ app/__init__.py | 1 + app/config.py | 15 ++++++ app/database.py | 30 ++++++++++++ app/main.py | 27 +++++++++++ app/models.py | 18 +++++++ app/routes.py | 18 +++++++ architecture.md | 109 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 24 ++++++++++ dockerfile | 31 ++++++++++++ readme.md | 88 ++++++++++++++++++++++++++++++++++ requirements-dev.txt | 2 + requirements.txt | 4 ++ tests/__init__.py | 1 + tests/test_main.py | 56 ++++++++++++++++++++++ 16 files changed, 460 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 architecture.md create mode 100644 docker-compose.yml create mode 100644 dockerfile create mode 100644 readme.md create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_main.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf441c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Database settings +DATABASE_URL=sqlite:///data/app.db +DEBUG=false +API_PORT=8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4db76fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ + +# Data +data/ +*.db +*.sqlite3 + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker +.dockerignore + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..4cf803a --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# micro-api \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..84db309 --- /dev/null +++ b/app/config.py @@ -0,0 +1,15 @@ +"""Application configuration — env-driven, zero secrets hardcoded.""" + +import os + + +class Settings: + APP_NAME: str = "micro-api" + VERSION: str = "0.1.0" + DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" + DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///data/app.db") + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", "8000")) + + +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..dc26cd6 --- /dev/null +++ b/app/database.py @@ -0,0 +1,30 @@ +"""Database utilities — SQLite for local/dev, prepared for Postgres.""" + +import sqlite3 +from pathlib import Path + +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "app.db" + + +def get_connection() -> sqlite3.Connection: + """Return a new SQLite connection with WAL mode and foreign keys enabled.""" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + """Initialize database schema.""" + conn = get_connection() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + conn.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7890eb8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,27 @@ +"""FastAPI micro-service — entry point.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routes import router + +app = FastAPI( + title="Micro-API", + description="Micro-services API — lightweight, scalable foundation.", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(router) + + +@app.get("/") +async def root(): + return {"service": "micro-api", "status": "running"} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..96f8d27 --- /dev/null +++ b/app/models.py @@ -0,0 +1,18 @@ +"""Pydantic models for request/response schemas.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class ItemCreate(BaseModel): + name: str + + +class ItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + created_at: Optional[datetime] = None diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..a3b5caa --- /dev/null +++ b/app/routes.py @@ -0,0 +1,18 @@ +"""API routes.""" + +from datetime import datetime, timezone + +from fastapi import APIRouter + +router = APIRouter(prefix="/api") + + +@router.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "healthy", + "service": "micro-api", + "version": "0.1.0", + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..378c485 --- /dev/null +++ b/architecture.md @@ -0,0 +1,109 @@ +# ARCHITECTURE.md — Micro-API + +## Overview + +Micro-API is the foundation service of the organization's micro-services platform. It provides a lightweight HTTP API designed to be deployed on a single VPS and scaled horizontally as needs grow. + +## Design Principles + +1. **Zero cost** — No paid services, no cloud dependencies. Runs on the existing VPS behind Traefik/Caddy. +2. **Simplicity first** — SQLite for local dev and single-node deploys. Postgres optional when multi-service. +3. **Containerized** — Every service ships as a Docker container, orchestrated via docker-compose. +4. **Stateless API** — Health-checkable, horizontally scalable behind a reverse proxy. +5. **Tested from day one** — pytest on every service before deploy. + +## System Architecture + +``` +┌──────────────────────────────────────────┐ +│ VPS (82.165.176.5) │ +│ │ +│ ┌─────────┐ ┌──────────────────────┐ │ +│ │ Traefik │──▶│ micro-api:8000 │ │ +│ │ :443 │ │ (Docker container) │ │ +│ └─────────┘ │ FastAPI + Uvicorn │ │ +│ │ SQLite /data/ │ │ +│ └──────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Future services (API v2, ...) │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +## Data Flow + +``` +Client ──HTTPS──▶ Traefik ──HTTP──▶ micro-api:8000 + │ + ▼ + SQLite (file) + /data/app.db +``` + +## Database Strategy + +### Phase 1 — SQLite (current) +- Single file: `data/app.db` +- WAL mode enabled for concurrent reads +- Docker volume-mounted for persistence +- Backups via `sqlite3 .backup` or file copy during VPS backup window + +### Phase 2 — Postgres (optional) +- Add `psycopg2` to requirements +- Set `DATABASE_URL=postgresql://...` +- Add a `postgres` service to `docker-compose.yml` +- Run migrations via Alembic + +## Deployment Plan + +### Target +- VPS: `82.165.176.5` (founder@82.165.176.5) +- Path: `/opt/micro-api/` +- Managed via: `docker compose` + +### Steps (not yet executed) +1. Push repo to Gitea: `http://82.165.176.5` +2. SSH to VPS, clone to `/opt/micro-api/` +3. `cp .env.example .env` and adjust +4. `docker compose up -d --build` +5. Verify: `curl http://localhost:8000/api/health` +6. Add Traefik route label to docker-compose + +### Reverse Proxy Integration + +Add these labels to `docker-compose.yml` when Traefik is running: + +```yaml +labels: + - "traefik.enable=true" + - "traefik.http.routers.micro-api.rule=Host(`api.your-domain.com`)" + - "traefik.http.services.micro-api.loadbalancer.server.port=8000" +``` + +## Security + +- No secrets in code — everything via env vars +- `.env` is gitignored, `.env.example` is the template +- CORS is open (`*`) for now — restrict in production +- Container runs as non-root in future iteration + +## Testing Strategy + +- **Unit tests**: pytest on all routes, models, and DB utilities +- **Integration**: `TestClient` against the FastAPI app +- **Smoke test**: `curl /api/health` after deploy +- **CI**: GitHub Actions / Gitea Actions (future) + +## Future Services + +Each new micro-service follows the same scaffold: +1. Copy this template +2. Add service-specific routes/models +3. Add to the main `docker-compose.yml` +4. Register with Traefik + +## Maintainers + +- **Founder** — architecture decisions, deployment +- **AutoClaw (forge agent)** — initial scaffold, CI/CD diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eed84b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: micro-api + ports: + - "${API_PORT:-8000}:8000" + environment: + - DEBUG=${DEBUG:-false} + - DATABASE_URL=${DATABASE_URL:-sqlite:///data/app.db} + volumes: + - api_data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + api_data: + driver: local diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..259863f --- /dev/null +++ b/dockerfile @@ -0,0 +1,31 @@ +# ---- Build stage ---- +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Install dependencies in a venv +COPY requirements.txt . +RUN python -m venv /opt/venv && \ + /opt/venv/bin/pip install --no-cache-dir -r requirements.txt + +# ---- Runtime stage ---- +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# Copy virtualenv from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application code +COPY app/ ./app/ + +# Create data dir for SQLite +RUN mkdir -p /app/data && chmod 777 /app/data + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..355926d --- /dev/null +++ b/readme.md @@ -0,0 +1,88 @@ +# Micro-API + +Lightweight FastAPI micro-service foundation. Docker-ready, zero-cost deployment. + +## Stack + +- **Python 3.12** — slim Docker image +- **FastAPI** — async web framework +- **Uvicorn** — ASGI server +- **SQLite** — local database (swap to Postgres via `DATABASE_URL`) +- **Docker + Docker Compose** — containerized deployment + +## Quick Start + +### Local dev + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt -r requirements-dev.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +Open http://localhost:8000/api/health + +### Docker + +```bash +docker compose up --build +``` + +### Tests + +```bash +pytest -v +``` + +## Endpoints + +| Method | Path | Description | +|--------|----------------|-----------------| +| GET | `/` | Service info | +| GET | `/api/health` | Health check | + +## Environment + +Copy `.env.example` to `.env` and adjust: + +| Variable | Default | Description | +|----------------|-------------------------|----------------------| +| `DATABASE_URL` | `sqlite:///data/app.db` | Database connection | +| `DEBUG` | `false` | Enable debug mode | +| `API_PORT` | `8000` | Host port binding | + +## Deployment + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the full deployment plan. + +```bash +# On the VPS +git clone /opt/micro-api +cd /opt/micro-api +docker compose up -d --build +``` + +## Project Structure + +``` +micro-api/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app entry point +│ ├── routes.py # API routes +│ ├── models.py # Pydantic schemas +│ ├── database.py # SQLite utilities +│ └── config.py # App settings +├── tests/ +│ ├── __init__.py +│ └── test_main.py # API tests +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +├── requirements-dev.txt +├── .env.example +├── .gitignore +├── README.md +└── ARCHITECTURE.md +``` diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9b854f1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8.0.0,<9.0.0 +httpx>=0.28.0,<1.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e208b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0,<1.0.0 +uvicorn[standard]>=0.34.0,<1.0.0 +pydantic>=2.0.0,<3.0.0 +python-dotenv>=1.0.0,<2.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..15c6579 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..9b4bfe0 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,56 @@ +"""Tests for the FastAPI application.""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +class TestHealth: + def test_health_endpoint_returns_200(self): + response = client.get("/api/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["service"] == "micro-api" + assert data["version"] == "0.1.0" + assert "timestamp" in data + + def test_root_returns_service_info(self): + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data["service"] == "micro-api" + assert data["status"] == "running" + + +class TestDatabase: + def test_init_db_creates_tables(self): + from app.database import init_db + + # Should not raise + init_db() + + +class TestModels: + def test_item_create_validation(self): + from app.models import ItemCreate + + item = ItemCreate(name="test-item") + assert item.name == "test-item" + + def test_item_create_requires_name(self): + from app.models import ItemCreate + + with pytest.raises(Exception): + ItemCreate() + + def test_item_response_serialization(self): + from app.models import ItemResponse + + item = ItemResponse(id=1, name="test") + data = item.model_dump() + assert data["id"] == 1 + assert data["name"] == "test"