Initial scaffold: FastAPI micro-service with Docker, SQLite, tests
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# Database settings
|
||||||
|
DATABASE_URL=sqlite:///data/app.db
|
||||||
|
DEBUG=false
|
||||||
|
API_PORT=8000
|
||||||
+32
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# micro-api
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+27
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
+109
@@ -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
|
||||||
@@ -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
|
||||||
+31
@@ -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"]
|
||||||
@@ -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 <repo-url> /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
|
||||||
|
```
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest>=8.0.0,<9.0.0
|
||||||
|
httpx>=0.28.0,<1.0.0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# tests
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user