From 6659d2321c5e366a3e006ad2d54d0a408efec01a Mon Sep 17 00:00:00 2001 From: iklo Date: Tue, 19 May 2026 11:46:15 -0300 Subject: [PATCH 01/15] feat(docker): publish ready-to-use iklobato/lightapi image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-user flow: docker run --rm -p 8000:8000 \ -v ./lightapi.yaml:/app/lightapi.yaml:ro \ -e DATABASE_URL=sqlite:////app/data.db \ iklobato/lightapi:latest What ships: - Dockerfile (python:3.12-slim base, multi-arch friendly). Pre-installs lightapi[async] + psycopg2-binary so both sync PostgreSQL and async asyncpg/aiosqlite engines work out of the box. Drops privileges to a non-root `lightapi` user. Build arg LIGHTAPI_VERSION pins the wheel the workflow installs (kept in sync with pyproject.toml at publish time). - docker/entrypoint.py: 60-line launcher that locates the YAML at /app/lightapi.yaml (overridable via LIGHTAPI_CONFIG), runs LightApi.from_config() + build_app(), and starts uvicorn with proxy_headers=True so common reverse-proxy headers are respected. - docker/lightapi.example.yaml: minimal config used as a smoke-test fixture. - .dockerignore: excludes .git, .venv, tests, docs, examples, site, etc. - .github/workflows/docker-publish.yml: builds linux/amd64 + linux/arm64 on master pushes and v*.*.* tag pushes, pushes to iklobato/lightapi using DOCKERHUB_USERNAME + DOCKERHUB_TOKEN repo secrets. Tag rules: v0.1.21 tag → :0.1.21, :0.1, :latest master push → :master workflow_dispatch → :manual- Docs: - README: new Docker quickstart block. - docs/deployment/docker.md: complete rewrite covering quickstart, env vars, SQLite-with-volume, docker compose + PostgreSQL, Redis cache, JWT auth with a mounted login_validator module, custom image extension pattern, and the publish-pipeline secrets. Verified locally with docker build + docker run; POST /books, GET /books, POST /authors, GET /authors all return the expected responses against the bundled example config. --- .dockerignore | 29 +++ .github/workflows/docker-publish.yml | 92 ++++++++++ Dockerfile | 65 +++++++ README.md | 11 ++ docker/entrypoint.py | 79 +++++++++ docker/lightapi.example.yaml | 32 ++++ docs/deployment/docker.md | 252 ++++++++++++++++++++++++--- 7 files changed, 534 insertions(+), 26 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile create mode 100644 docker/entrypoint.py create mode 100644 docker/lightapi.example.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96549e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +.git +.github +.venv +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +.cursor +.cursorrules +.specify +__pycache__ +**/__pycache__ +*.pyc +*.pyo +*.pyd +*.db +*.log +dist +build +site +docs +examples +tests +overrides +mkdocs.yml +README.md +LICENSE +*.md +specs diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..e6a1484 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,92 @@ +name: Publish Docker image + +# Builds the ready-to-use LightAPI image and pushes it to Docker Hub. +# +# Triggers: +# - on push of a `v*` tag (release) → tags the image with that version + `latest` +# - on push to `master` → tags the image with `master` +# - manual workflow_dispatch → tags the image with `manual-` +# +# Required repository secrets: +# - DOCKERHUB_USERNAME Docker Hub username (e.g. `iklobato`) +# - DOCKERHUB_TOKEN Docker Hub access token (read+write+delete scope) + +on: + push: + tags: ["v*.*.*"] + branches: ["master"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + name: Build & push iklobato/lightapi + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Read package version + id: version + shell: bash + run: | + # Extract version from pyproject.toml (e.g. 0.1.21) + VERSION=$(python -c " + import tomllib, pathlib + data = tomllib.loads(pathlib.Path('pyproject.toml').read_text()) + print(data['project']['version']) + ") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Detected lightapi version: $VERSION" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Compute image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: iklobato/lightapi + tags: | + # On a v*.*.* tag → :0.1.21, :0.1, :latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + # On a push to master → :master (rolling head) + type=raw,value=master,enable=${{ github.ref == 'refs/heads/master' }} + # Manual run → :manual- + type=raw,value=manual-${{ github.run_number }},enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + LIGHTAPI_VERSION=${{ steps.version.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image digest summary + run: | + echo "### Published image" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Tags:" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd4bc5b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1.7 + +# Ready-to-run LightAPI image. +# Users mount their config at /app/lightapi.yaml and run the container — +# no Python code or build step required on their side. +# +# Build: docker build -t lightapi:local . +# Run: docker run --rm -p 8000:8000 \ +# -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ +# -e DATABASE_URL=sqlite:////app/data.db \ +# lightapi:local + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + LIGHTAPI_CONFIG=/app/lightapi.yaml \ + LIGHTAPI_HOST=0.0.0.0 \ + LIGHTAPI_PORT=8000 \ + LIGHTAPI_LOG_LEVEL=info + +WORKDIR /app + +# Build dependencies for psycopg2 / asyncpg native code. Keep build-essential +# in the layer so we can compile, then remove it. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install LightAPI + async + PostgreSQL drivers. We use the published wheel +# rather than copying the source so the image stays useful even when this +# Dockerfile is built outside the repo. +ARG LIGHTAPI_VERSION +RUN if [ -n "$LIGHTAPI_VERSION" ]; then \ + pip install "lightapi[async]==$LIGHTAPI_VERSION" psycopg2-binary ; \ + else \ + pip install "lightapi[async]" psycopg2-binary ; \ + fi \ + && apt-get purge -y --auto-remove build-essential \ + && rm -rf /root/.cache + +# Copy the launcher. Everything user-supplied lives outside /app/entrypoint.py. +COPY docker/entrypoint.py /app/entrypoint.py + +# Non-root runtime user +RUN groupadd --system --gid 1001 lightapi \ + && useradd --system --uid 1001 --gid lightapi --home-dir /app --no-create-home lightapi \ + && chown -R lightapi:lightapi /app +USER lightapi + +EXPOSE 8000 + +# Lightweight health check — touches the root path expecting any HTTP response. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request, sys; \ +import os; \ +url='http://127.0.0.1:'+os.environ.get('LIGHTAPI_PORT','8000')+'/'; \ +sys.exit(0 if urllib.request.urlopen(url, timeout=2).status else 1)" \ + || exit 1 + +ENTRYPOINT ["python", "/app/entrypoint.py"] diff --git a/README.md b/README.md index 07b22f4..0591f45 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ uv add "lightapi[async]" **Optional Redis caching**: `redis` is included as a core dependency but Redis caching only activates when `Meta.cache = Cache(ttl=N)` is set on an endpoint. A `RuntimeWarning` is emitted at startup if Redis is unreachable. +**Docker (no install required)**: run the API straight from the published image — just mount your config: + +```bash +docker run --rm -p 8000:8000 \ + -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ + -e DATABASE_URL=sqlite:////app/data.db \ + iklobato/lightapi:latest +``` + +See [Docker deployment](docs/deployment/docker.md) for the full guide. + --- ## Quick Start diff --git a/docker/entrypoint.py b/docker/entrypoint.py new file mode 100644 index 0000000..26dd39e --- /dev/null +++ b/docker/entrypoint.py @@ -0,0 +1,79 @@ +"""Container entrypoint: load lightapi.yaml and start uvicorn. + +Resolved configuration values, in order of precedence: + +1. Environment variables: + - LIGHTAPI_CONFIG Path to the YAML config (default /app/lightapi.yaml). + - LIGHTAPI_HOST Host for uvicorn to bind (default 0.0.0.0). + - LIGHTAPI_PORT Port for uvicorn (default 8000). + - LIGHTAPI_LOG_LEVEL Uvicorn log level (default info). + - DATABASE_URL Substituted into ${DATABASE_URL} placeholders in the YAML. + - LIGHTAPI_JWT_SECRET Required when the config uses JWT authentication. +2. Defaults baked into this script. + +The YAML schema is documented at +https://iklobato.github.io/lightapi/getting-started/configuration/ +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +import uvicorn + +from lightapi import LightApi + +logger = logging.getLogger("lightapi.entrypoint") + + +def _resolve_config_path() -> Path: + path = Path(os.environ.get("LIGHTAPI_CONFIG", "/app/lightapi.yaml")) + if path.exists(): + return path + + sys.stderr.write( + f"LightAPI config file not found at {path}.\n" + "Mount your config into the container, for example:\n" + " docker run -v ./lightapi.yaml:/app/lightapi.yaml lightapi\n" + "Or set LIGHTAPI_CONFIG to a different path.\n" + ) + sys.exit(2) + + +def main() -> None: + logging.basicConfig( + level=os.environ.get("LIGHTAPI_LOG_LEVEL", "info").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + config_path = _resolve_config_path() + logger.info("Loading LightAPI config from %s", config_path) + + try: + app = LightApi.from_config(str(config_path)) + except Exception: + logger.exception("Failed to load LightAPI config") + sys.exit(3) + + starlette_app = app.build_app() + + host = os.environ.get("LIGHTAPI_HOST", "0.0.0.0") + port = int(os.environ.get("LIGHTAPI_PORT", "8000")) + log_level = os.environ.get("LIGHTAPI_LOG_LEVEL", "info").lower() + + logger.info("Starting uvicorn on %s:%s", host, port) + uvicorn.run( + starlette_app, + host=host, + port=port, + log_level=log_level, + proxy_headers=True, + forwarded_allow_ips="*", + ) + + +if __name__ == "__main__": + main() diff --git a/docker/lightapi.example.yaml b/docker/lightapi.example.yaml new file mode 100644 index 0000000..4626013 --- /dev/null +++ b/docker/lightapi.example.yaml @@ -0,0 +1,32 @@ +# Minimal LightAPI config — mount this into the container at /app/lightapi.yaml. +# +# docker run --rm -p 8000:8000 \ +# -v "$(pwd)/lightapi.example.yaml:/app/lightapi.yaml:ro" \ +# -e DATABASE_URL=sqlite:////app/data.db \ +# iklobato/lightapi:latest +# +# See https://iklobato.github.io/lightapi/getting-started/configuration/ for +# the full YAML schema (auth, filtering, pagination, per-method permissions, +# YAML reflection, defaults, middleware, etc.). + +database: + url: "${DATABASE_URL}" # ${VAR} env-var substitution + +endpoints: + - route: /books + fields: + title: { type: str, min_length: 1, max_length: 200 } + author: { type: str, min_length: 1 } + year: { type: int, optional: true } + meta: + methods: [GET, POST, PUT, PATCH, DELETE] + pagination: + style: page_number + page_size: 25 + + - route: /authors + fields: + name: { type: str, min_length: 1 } + bio: { type: str, optional: true } + meta: + methods: [GET, POST] diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 732da22..acc4597 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -1,50 +1,250 @@ --- title: Docker Deployment +description: Run a fully-configured LightAPI server in seconds with the official iklobato/lightapi image --- -## Dockerfile +# Docker Deployment -```dockerfile -# Use official Python base image -FROM python:3.10-slim +The official image bundles LightAPI, its async extras, and PostgreSQL drivers. +Mount a YAML config and a database URL — no Python code or build step required. + +``` +iklobato/lightapi:latest +iklobato/lightapi: # e.g. 0.1.21 +iklobato/lightapi:master # rolling head of master +``` + +The image is multi-arch (`linux/amd64`, `linux/arm64`) and is built from the +[`Dockerfile`](https://github.com/iklobato/LightAPI/blob/master/Dockerfile) +at the repository root. + +## Quickstart + +1. Write a config file (`lightapi.yaml`): + + ```yaml + database: + url: "${DATABASE_URL}" + + endpoints: + - route: /books + fields: + title: { type: str, min_length: 1, max_length: 200 } + author: { type: str, min_length: 1 } + year: { type: int, optional: true } + meta: + methods: [GET, POST, PUT, PATCH, DELETE] + pagination: + style: page_number + page_size: 25 + ``` + +2. Start the container: + + ```bash + docker run --rm -p 8000:8000 \ + -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ + -e DATABASE_URL=sqlite:////app/data.db \ + iklobato/lightapi:latest + ``` + +3. Use the API: + + ```bash + curl -X POST http://localhost:8000/books \ + -H 'Content-Type: application/json' \ + -d '{"title": "Clean Code", "author": "Martin", "year": 2008}' + curl http://localhost:8000/books + ``` + +That's the whole flow — no `pip install`, no manual `LightApi(...)`. The +container reads `/app/lightapi.yaml` on start, builds the app, and starts +uvicorn on port 8000. + +## Configuration surface -# Set working directory -WORKDIR /app +### Mount points -# Copy requirements and install -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +| Path | Purpose | +|---|---| +| `/app/lightapi.yaml` | Your endpoint config (the only required mount). | +| `/app/data.db` | Suggested location for an on-disk SQLite database (mount a volume to persist it). | -# Copy application code -COPY . . +### Environment variables -# Expose port and run -EXPOSE 8000 -CMD ["gunicorn", "app.main:app", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"] +| Variable | Default | Description | +|---|---|---| +| `LIGHTAPI_CONFIG` | `/app/lightapi.yaml` | Override the config path. | +| `LIGHTAPI_HOST` | `0.0.0.0` | Uvicorn bind address. | +| `LIGHTAPI_PORT` | `8000` | Uvicorn port. | +| `LIGHTAPI_LOG_LEVEL` | `info` | `debug`, `info`, `warning`, `error`. | +| `DATABASE_URL` | — | Substituted into `${DATABASE_URL}` in your YAML. | +| `LIGHTAPI_JWT_SECRET` | — | Required when your config enables JWT authentication. | +| `LIGHTAPI_REDIS_URL` | `redis://localhost:6379/0` | Used when `Cache(...)` is configured. | + +Any other `${VAR}` placeholder in the YAML is resolved against the +container environment at startup. + +## Examples + +### SQLite + persistent volume + +```bash +docker volume create lightapi-data +docker run -d --name lightapi \ + -p 8000:8000 \ + -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ + -v lightapi-data:/app \ + -e DATABASE_URL=sqlite:////app/data.db \ + iklobato/lightapi:latest ``` -## docker-compose.yml +### PostgreSQL via docker compose ```yaml -version: '3.8' +# docker-compose.yml services: api: - build: . + image: iklobato/lightapi:latest ports: - "8000:8000" environment: - - LIGHTAPI_DATABASE_URL=sqlite:///./app.db - - LIGHTAPI_JWT_SECRET=supersecret + DATABASE_URL: postgresql+asyncpg://api:apipass@db:5432/api + LIGHTAPI_JWT_SECRET: replace-me-in-production + volumes: + - ./lightapi.yaml:/app/lightapi.yaml:ro depends_on: - - redis - redis: - image: redis:7-alpine - ports: - - "6379:6379" + db: + condition: service_healthy + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: api + POSTGRES_PASSWORD: apipass + POSTGRES_DB: api + healthcheck: + test: ["CMD", "pg_isready", "-U", "api"] + interval: 5s + retries: 5 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: +``` + +```bash +docker compose up -d ``` -### Building and Running +When `database.url` resolves to a `postgresql+asyncpg://...` URL, LightAPI +auto-detects the async engine and serves every endpoint asynchronously. + +### Redis cache ```bash -docker-compose up --build +docker run --rm -p 8000:8000 \ + -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ + -e DATABASE_URL=sqlite:////app/data.db \ + -e LIGHTAPI_REDIS_URL=redis://redis:6379/0 \ + --network my-net \ + iklobato/lightapi:latest ``` + +If Redis is unreachable, LightAPI logs a `RuntimeWarning` once at startup +and falls back to serving from the database — your endpoints keep working. + +### JWT authentication + +```yaml +# lightapi.yaml +database: + url: "${DATABASE_URL}" + +defaults: + authentication: + backend: JWTAuthentication + permission: IsAuthenticated + +endpoints: + - route: /books + fields: + title: { type: str } + author: { type: str } + meta: + methods: [GET, POST, PUT, DELETE] + +auth: + login_validator: myapp.auth.validate_login +``` + +```bash +docker run --rm -p 8000:8000 \ + -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ + -v "$(pwd)/myapp:/app/myapp:ro" \ + -e PYTHONPATH=/app \ + -e DATABASE_URL=sqlite:////app/data.db \ + -e LIGHTAPI_JWT_SECRET=replace-me \ + iklobato/lightapi:latest +``` + +`POST /auth/login` is auto-registered because at least one endpoint declares +`JWTAuthentication`. See +[Authentication](../advanced/authentication.md) for the credential-validator +contract. + +## Custom image + +If you need extra Python dependencies (a custom `login_validator`, custom +middleware, etc.), extend the published image: + +```dockerfile +FROM iklobato/lightapi:0.1.21 + +# Add your own modules — they must be importable from the YAML's dotted paths. +COPY ./myapp /app/myapp +ENV PYTHONPATH=/app + +# Add any extra Python deps. +RUN pip install --no-cache-dir requests redis +``` + +## Image build internals + +The Dockerfile uses `python:3.12-slim`, installs LightAPI with the `async` +extra plus PostgreSQL drivers, drops privileges to a non-root `lightapi` +user, and ships a small entrypoint (`docker/entrypoint.py`) that: + +1. Resolves the config path (`LIGHTAPI_CONFIG`, default `/app/lightapi.yaml`). +2. Calls `LightApi.from_config(path)`. +3. Calls `app.build_app()` and runs uvicorn with `proxy_headers=True` and + `forwarded_allow_ips="*"` so reverse-proxy headers (`X-Forwarded-For`, + `X-Forwarded-Proto`) are honoured. + +To build the image locally: + +```bash +git clone https://github.com/iklobato/LightAPI.git +cd LightAPI +docker build --build-arg LIGHTAPI_VERSION=0.1.21 -t lightapi:local . +``` + +## Publishing pipeline + +`master` pushes and every `v*.*.*` tag trigger +`.github/workflows/docker-publish.yml`, which builds the multi-arch image +and pushes to Docker Hub. The workflow needs two repository secrets: + +| Secret | Value | +|---|---| +| `DOCKERHUB_USERNAME` | Your Docker Hub username (`iklobato`). | +| `DOCKERHUB_TOKEN` | A Docker Hub access token with read+write scope on `iklobato/lightapi`. | + +Tag schedule: + +| Event | Tags pushed | +|---|---| +| Push of `v0.1.21` | `iklobato/lightapi:0.1.21`, `:0.1`, `:latest` | +| Push to `master` | `iklobato/lightapi:master` | +| Manual `workflow_dispatch` | `iklobato/lightapi:manual-` | From a6c91149978e08a31b6254a572d69327ef2b4258 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 19 May 2026 15:04:16 +0000 Subject: [PATCH 02/15] Bump version to 0.1.22 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cde68dc..917b7c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "lightapi" -version = "0.1.21" +version = "0.1.22" description = "A lightweight framework for building API endpoints using Python's native libraries." readme = "README.md" requires-python = ">=3.10" From eff1ccf8618c4581c5f09f5178bd2f9c26d5cf5f Mon Sep 17 00:00:00 2001 From: iklo Date: Tue, 19 May 2026 12:10:10 -0300 Subject: [PATCH 03/15] fix(docker): publish under iklob1/lightapi instead of iklobato/lightapi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DOCKERHUB_TOKEN authenticates against the Docker Hub account `iklob1` (verified by a successful `docker login -u iklob1`), not the GitHub-username `iklobato`. Updated the image references in: - .github/workflows/docker-publish.yml — image and job name - README.md — Docker quickstart pull line - docker/lightapi.example.yaml — header comment - docs/deployment/docker.md — every image reference + secret table `github.com/iklobato/...` URLs are unchanged (still the GitHub repo). --- .github/workflows/docker-publish.yml | 4 ++-- README.md | 2 +- docker/lightapi.example.yaml | 2 +- docs/deployment/docker.md | 30 ++++++++++++++-------------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e6a1484..211683d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,7 +22,7 @@ permissions: jobs: publish: - name: Build & push iklobato/lightapi + name: Build & push iklob1/lightapi runs-on: ubuntu-latest steps: @@ -58,7 +58,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: iklobato/lightapi + images: iklob1/lightapi tags: | # On a v*.*.* tag → :0.1.21, :0.1, :latest type=semver,pattern={{version}} diff --git a/README.md b/README.md index 0591f45..6e403d6 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ uv add "lightapi[async]" docker run --rm -p 8000:8000 \ -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ -e DATABASE_URL=sqlite:////app/data.db \ - iklobato/lightapi:latest + iklob1/lightapi:latest ``` See [Docker deployment](docs/deployment/docker.md) for the full guide. diff --git a/docker/lightapi.example.yaml b/docker/lightapi.example.yaml index 4626013..4d33f10 100644 --- a/docker/lightapi.example.yaml +++ b/docker/lightapi.example.yaml @@ -3,7 +3,7 @@ # docker run --rm -p 8000:8000 \ # -v "$(pwd)/lightapi.example.yaml:/app/lightapi.yaml:ro" \ # -e DATABASE_URL=sqlite:////app/data.db \ -# iklobato/lightapi:latest +# iklob1/lightapi:latest # # See https://iklobato.github.io/lightapi/getting-started/configuration/ for # the full YAML schema (auth, filtering, pagination, per-method permissions, diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index acc4597..d6f4eb2 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -1,6 +1,6 @@ --- title: Docker Deployment -description: Run a fully-configured LightAPI server in seconds with the official iklobato/lightapi image +description: Run a fully-configured LightAPI server in seconds with the official iklob1/lightapi image --- # Docker Deployment @@ -9,9 +9,9 @@ The official image bundles LightAPI, its async extras, and PostgreSQL drivers. Mount a YAML config and a database URL — no Python code or build step required. ``` -iklobato/lightapi:latest -iklobato/lightapi: # e.g. 0.1.21 -iklobato/lightapi:master # rolling head of master +iklob1/lightapi:latest +iklob1/lightapi: # e.g. 0.1.21 +iklob1/lightapi:master # rolling head of master ``` The image is multi-arch (`linux/amd64`, `linux/arm64`) and is built from the @@ -45,7 +45,7 @@ at the repository root. docker run --rm -p 8000:8000 \ -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ -e DATABASE_URL=sqlite:////app/data.db \ - iklobato/lightapi:latest + iklob1/lightapi:latest ``` 3. Use the API: @@ -96,7 +96,7 @@ docker run -d --name lightapi \ -v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \ -v lightapi-data:/app \ -e DATABASE_URL=sqlite:////app/data.db \ - iklobato/lightapi:latest + iklob1/lightapi:latest ``` ### PostgreSQL via docker compose @@ -105,7 +105,7 @@ docker run -d --name lightapi \ # docker-compose.yml services: api: - image: iklobato/lightapi:latest + image: iklob1/lightapi:latest ports: - "8000:8000" environment: @@ -149,7 +149,7 @@ docker run --rm -p 8000:8000 \ -e DATABASE_URL=sqlite:////app/data.db \ -e LIGHTAPI_REDIS_URL=redis://redis:6379/0 \ --network my-net \ - iklobato/lightapi:latest + iklob1/lightapi:latest ``` If Redis is unreachable, LightAPI logs a `RuntimeWarning` once at startup @@ -186,7 +186,7 @@ docker run --rm -p 8000:8000 \ -e PYTHONPATH=/app \ -e DATABASE_URL=sqlite:////app/data.db \ -e LIGHTAPI_JWT_SECRET=replace-me \ - iklobato/lightapi:latest + iklob1/lightapi:latest ``` `POST /auth/login` is auto-registered because at least one endpoint declares @@ -200,7 +200,7 @@ If you need extra Python dependencies (a custom `login_validator`, custom middleware, etc.), extend the published image: ```dockerfile -FROM iklobato/lightapi:0.1.21 +FROM iklob1/lightapi:0.1.21 # Add your own modules — they must be importable from the YAML's dotted paths. COPY ./myapp /app/myapp @@ -238,13 +238,13 @@ and pushes to Docker Hub. The workflow needs two repository secrets: | Secret | Value | |---|---| -| `DOCKERHUB_USERNAME` | Your Docker Hub username (`iklobato`). | -| `DOCKERHUB_TOKEN` | A Docker Hub access token with read+write scope on `iklobato/lightapi`. | +| `DOCKERHUB_USERNAME` | Your Docker Hub username (`iklob1`). | +| `DOCKERHUB_TOKEN` | A Docker Hub access token with read+write scope on `iklob1/lightapi`. | Tag schedule: | Event | Tags pushed | |---|---| -| Push of `v0.1.21` | `iklobato/lightapi:0.1.21`, `:0.1`, `:latest` | -| Push to `master` | `iklobato/lightapi:master` | -| Manual `workflow_dispatch` | `iklobato/lightapi:manual-` | +| Push of `v0.1.21` | `iklob1/lightapi:0.1.21`, `:0.1`, `:latest` | +| Push to `master` | `iklob1/lightapi:master` | +| Manual `workflow_dispatch` | `iklob1/lightapi:manual-` | From cf06583ac007560744dbd54db20d31a9b67e829f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 19 May 2026 15:12:51 +0000 Subject: [PATCH 04/15] Bump version to 0.1.23 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 917b7c7..610bcbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "lightapi" -version = "0.1.22" +version = "0.1.23" description = "A lightweight framework for building API endpoints using Python's native libraries." readme = "README.md" requires-python = ">=3.10" From ec7809747e3c50348d8861c7fbb701bf540196d5 Mon Sep 17 00:00:00 2001 From: iklo Date: Fri, 29 May 2026 20:52:47 -0300 Subject: [PATCH 05/15] fix: patch null-clearing, search escaping, ordering whitelist, from_dict fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes - login_validator exceptions now return 401 instead of 500 - SearchFilter escapes % and _ so search terms are treated as literals - OrderingFilter with empty whitelist now disables ordering entirely - PATCH null clears Optional/nullable fields (was silently ignored) - from_dict fields were silently dropped (missing __annotations__) - from_dict methods list now enforces HTTP verbs via HttpMethod mixins Tests - Update test_login_validator_exception_returns_500 → asserts 401 Documentation - Complete LightApi() constructor signature in README - Correct rate_limiter dict keys and scope (auth/login only) - Document SearchFilter literal matching and OrderingFilter whitelist - Document PATCH null-clearing semantics for Optional fields - Document login_validator exception → 401 behavior - Add CHANGELOG.md --- CHANGELOG.md | 135 ++++++++++++++++++++++++++++++++ README.md | 72 +++++++++++++---- docs/advanced/authentication.md | 2 + docs/advanced/filtering.md | 4 +- docs/api-reference/core.md | 4 +- docs/api-reference/filters.md | 4 +- docs/api-reference/rest.md | 13 +++ lightapi/_dict_config_loader.py | 57 +++++++------- lightapi/_login.py | 10 +-- lightapi/filters.py | 20 ++++- lightapi/rest.py | 32 +++++--- tests/test_login_auth.py | 7 +- 12 files changed, 290 insertions(+), 70 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d4e7fe9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,135 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Versions align with [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] — 0.1.24 + +### Fixed +- **`login_validator` exception → 401**: any exception raised by a `login_validator` + now returns `401 Unauthorized` (same as returning `None`) instead of propagating + as `500 Internal Server Error`. The exception is logged at `WARNING` level. +- **`SearchFilter` LIKE wildcard injection**: `%` and `_` in `?search=` values were + treated as SQL LIKE wildcards, causing `hello_world` to match `helloXworld` and + a bare `%` to match every row. Both characters are now escaped before the + `ILIKE` pattern is applied. +- **`OrderingFilter` empty whitelist**: when `Meta.filtering.ordering` was not set, + the guard `if allowed and …` short-circuited to allow ordering by any column. + An empty or omitted whitelist now disables ordering entirely, consistent with how + `FieldFilter` and `SearchFilter` handle unconfigured backends. +- **`PATCH null` clears `Optional` fields**: the `v is not None` guard in the PATCH + update path prevented users from ever clearing a nullable column. The guard now + consults the SQLAlchemy column inspector and allows `null` through for nullable + columns; non-nullable columns still ignore `null` values. +- **`from_dict` fields were silently dropped**: `LightApi.from_dict()` stored field + types as plain values without setting `__annotations__`, so `RestEndpointMeta` + never created the corresponding columns. All user-defined fields were absent from + every response. `__annotations__` is now populated before `type()` is called. +- **`from_dict` methods not enforced**: passing `methods=["GET", "POST"]` had no + effect because `class_attrs["__bases__"]` is ignored by `type()`. `HttpMethod` + mixins are now passed as real base-class arguments, so unlisted verbs correctly + return `405 Method Not Allowed`. + +### Changed +- `test_login_validator_exception_returns_500` updated to assert `401` and verify + the `"Invalid credentials"` response body, reflecting the corrected behaviour. + +### Documentation +- `README.md`: completed `LightApi()` constructor signature (added `mode`, + `auth_path`, `session_manager`, `rate_limiter`, `login_validator`, + `use_test_isolation`); corrected rate-limiter dict keys + (`requests_per_minute` / `requests_per_hour` / `requests_per_day`); clarified + that the rate limiter applies to `/auth/login` only; added notes on + `SearchFilter` literal matching, `OrderingFilter` whitelist requirement, PATCH + null-clearing for `Optional` fields, and `login_validator` exception handling. +- `docs/advanced/filtering.md`: added LIKE-literal paragraph and empty-whitelist + behavior for `OrderingFilter`. +- `docs/api-reference/filters.md`: same additions in API-reference form. +- `docs/advanced/authentication.md`: documented that validator exceptions yield 401. +- `docs/api-reference/rest.md`: added `### PATCH and Optional fields` subsection. +- `docs/api-reference/core.md`: expanded `login_validator` description; noted that + `from_dict` `methods` key enforces HTTP verbs. + +--- + +## [0.1.23] — 2025-01-xx + +### Fixed +- Docker image published under `iklob1/lightapi` instead of `iklobato/lightapi`. + +--- + +## [0.1.22] — 2025-01-xx + +### Added +- Published ready-to-use `iklob1/lightapi` Docker image with multi-arch support + (`linux/amd64`, `linux/arm64`). Mount a YAML config and run without any Python + install. + +--- + +## [0.1.21] — 2025-01-xx + +### Added +- 18 example scripts covering every LightAPI feature (`examples/01_minimal.py` + through `examples/18_full_api.py`). +- `mode` parameter on `LightApi` for explicit sync/async selection (auto-detected + from engine type and `async def` overrides when omitted). +- Global `rate_limiter` parameter on `LightApi`; configures the `/auth/login` + rate-limiter via `RateLimiter` instance or `{"requests_per_minute": N, …}` dict. +- `validate_credentials` support on auth backends. +- `authentication/` submodule replacing the flat `auth.py` module. + +### Fixed +- Async engine handling; `AsyncEngine` unwrapped correctly for sync callers. +- SQLAlchemy test-isolation pollution across test sessions. +- Auth-checker bugs with missing `Meta.authentication` configurations. + +### Changed +- `_registry.py` service-locator pattern removed; session management is now + injected directly via `SessionManager`. + +--- + +## [0.1.20] — 2025-01-xx + +### Changed +- Removed legacy v1 features and aligned documentation with v2 implementation. + +--- + +## [0.1.19] — 2025-01-xx + +### Changed +- Database connection now configured exclusively via `LIGHTAPI_DATABASE_URL` + environment variable when no `engine` or `database_url` argument is passed. + +--- + +## [0.1.18] — 2025-01-xx + +### Changed +- Linter and type-checker configuration aligned; `ruff` and `mypy` clean across + the core package. + +--- + +## [0.1.17] — 2025-01-xx + +### Fixed +- Test-suite failures in v2 integration tests resolved. + +--- + +[Unreleased]: https://github.com/iklobato/lightapi/compare/v0.1.23...HEAD +[0.1.23]: https://github.com/iklobato/lightapi/compare/v0.1.22...v0.1.23 +[0.1.22]: https://github.com/iklobato/lightapi/compare/v0.1.21...v0.1.22 +[0.1.21]: https://github.com/iklobato/lightapi/compare/v0.1.20...v0.1.21 +[0.1.20]: https://github.com/iklobato/lightapi/compare/v0.1.19...v0.1.20 +[0.1.19]: https://github.com/iklobato/lightapi/compare/v0.1.18...v0.1.19 +[0.1.18]: https://github.com/iklobato/lightapi/compare/v0.1.17...v0.1.18 +[0.1.17]: https://github.com/iklobato/lightapi/compare/v0.1.16...v0.1.17 diff --git a/README.md b/README.md index 6e403d6..4b33689 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,15 @@ curl -X PATCH http://localhost:8000/products/42 \ Missing `version` returns `422 Unprocessable Entity`. +> **Clearing Optional fields:** Send an explicit `null` value in a PATCH body to clear a nullable (`Optional[...]`) field: +> ```bash +> curl -X PATCH http://localhost:8000/products/42 \ +> -H "Content-Type: application/json" \ +> -d '{"description": null, "version": 4}' +> # → 200 {"id": 42, "description": null, ...} +> ``` +> Non-nullable fields ignore `null` values — they are treated as if the field was not sent. + ### HttpMethod Mixins Control which HTTP verbs your endpoint exposes by mixing in `HttpMethod.*` classes: @@ -350,25 +359,30 @@ app.register({"/secrets": ProtectedEndpoint}) # JWT mode: 200 {"token":"...","user":{...}}; Basic-only: 200 {"user":{...}} ``` -**Rate limiting:** Add per-endpoint rate limiting via `Authentication` config or global rate limiter: +Return `None` to reject a login attempt (`401 Unauthorized`). Raising an exception from `login_validator` is treated the same as returning `None` — the login attempt returns `401 Unauthorized`. The exception is logged at `WARNING` level and is not surfaced to the client. + +**Rate limiting:** Add a rate limiter on the `/auth/login` endpoint via `LightApi(rate_limiter=...)`: ```python from lightapi import RestEndpoint, Authentication from lightapi.authentication import JWTAuthentication +from lightapi.rate_limiter import RateLimiter -class LimitedEndpoint(RestEndpoint): - data: str = Field(min_length=1) - class Meta: - # Per-endpoint: 5 requests per minute - authentication = Authentication( - backend=JWTAuthentication, - rate_limiter={"requests": 5, "window": 60} - ) +# Using a RateLimiter instance +app = LightApi( + engine=engine, + rate_limiter=RateLimiter(requests_per_minute=5, requests_per_hour=100, requests_per_day=1000), +) -# Or global rate limiter (applied to all endpoints) -app = LightApi(engine=engine, rate_limiter={"requests": 100, "window": 60}) +# Or pass a dict with the same keys +app = LightApi( + engine=engine, + rate_limiter={"requests_per_minute": 100, "requests_per_hour": 1000, "requests_per_day": 5000}, +) ``` +> **Scope:** The rate limiter applies only to the `/auth/login` endpoint, not to application endpoints. + **Built-in permission classes:** | Class | Condition | @@ -406,11 +420,19 @@ class ArticleEndpoint(RestEndpoint): GET /articles?category=news # Full-text search across title and author -GET /articles?search=python +GET /articles?search=python # case-insensitive LIKE +``` +> **Search is literal:** `%` and `_` in the search term are treated as plain characters, not SQL wildcards. A search for `hello_world` matches only rows containing the literal string `hello_world`, not `helloXworld`. + +```bash # Ordering (prefix - for descending) GET /articles?ordering=-title +``` + +> **Whitelist required:** When `ordering` is not set (or empty), the `OrderingFilter` backend ignores all `?ordering=` parameters. Only fields explicitly listed in `ordering` can be sorted. +```bash # Combine all GET /articles?category=news&search=python&ordering=-title ``` @@ -842,19 +864,35 @@ asyncio_mode = auto ```python LightApi( - engine=None, # SQLAlchemy engine (takes priority over database_url) - database_url=None, # Fallback: create_engine(database_url) - cors_origins=None, # List[str] of allowed CORS origins - middlewares=None, # List[type] of Middleware subclasses + engine=None, # SQLAlchemy engine (takes priority over database_url) + database_url: str | None = None, # Fallback: create_engine(database_url) + mode: str | None = None, # "sync" or "async" — auto-detected if omitted + cors_origins: list[str] | None = None, # Allowed CORS origins + middlewares: list[type] | None = None, # Middleware subclasses to mount + auth_path: str = "/auth", # Prefix for auth endpoints (default "/auth") + session_manager: SessionManager | None = None, # Custom session/transaction manager + rate_limiter: "RateLimiter | dict[str, int] | None" = None, # Login rate limiter + login_validator: Callable[[str, str], dict[str, Any] | None] | None = None, + use_test_isolation: bool = False, # Enable per-test DB isolation ) ``` -| Method | Description | +| Method / Parameter | Description | |---|---| | `register(mapping)` | `{"/path": EndpointClass, ...}` — register endpoints and build routes | | `build_app()` | Create tables and return the Starlette ASGI app (for testing) | | `run(host, port, debug, reload)` | Create tables, check caches, start uvicorn | | `LightApi.from_config(path)` | Class method — construct from a YAML file | +| `engine` | SQLAlchemy engine (sync or async). Takes priority over `database_url`. | +| `database_url` | DSN string — a sync engine is created automatically. | +| `mode` | `"sync"` or `"async"`. Auto-detected from the engine type when omitted. | +| `cors_origins` | List of allowed CORS origins passed to `CORSMiddleware`. | +| `middlewares` | Additional Starlette middleware classes to mount. | +| `auth_path` | URL prefix for the auto-generated auth routes (default `"/auth"`). | +| `session_manager` | Supply a custom `SessionManager` for transaction handling. | +| `rate_limiter` | `RateLimiter` instance **or** dict — limits `/auth/login` requests only. | +| `login_validator` | Callable `(username, password) → dict \| None` — validates login credentials. | +| `use_test_isolation` | Wrap each request in a savepoint for isolated unit tests. | ### `RestEndpoint` diff --git a/docs/advanced/authentication.md b/docs/advanced/authentication.md index 69eeb9d..9f59671 100644 --- a/docs/advanced/authentication.md +++ b/docs/advanced/authentication.md @@ -90,6 +90,8 @@ def login_validator(username: str, password: str): app = LightApi(engine=engine, login_validator=login_validator) ``` +Raising an exception from `login_validator` is treated identically to returning `None` — the client receives `401 Unauthorized`. The exception is logged at `WARNING` level and is never surfaced to the API consumer. + The dict returned by the validator is the JWT payload. On JWT-protected apps, `POST /auth/login` returns `{"token": "", "user": {...}}`; the client then sends `Authorization: Bearer ` on subsequent requests. diff --git a/docs/advanced/filtering.md b/docs/advanced/filtering.md index 406ef9c..ca1dffc 100644 --- a/docs/advanced/filtering.md +++ b/docs/advanced/filtering.md @@ -76,6 +76,8 @@ GET /articles?search=async # WHERE title ILIKE '%async%' OR body ILIKE '%async%' ``` +**Search input is treated as a literal string.** The characters `%` and `_` (which are SQL LIKE wildcards) are automatically escaped before the pattern is applied. This means `?search=hello_world` matches only rows containing the exact substring `hello_world`, not rows where any single character appears in place of the underscore. A search for `100%` matches only the literal string `100%`, not "100 percent" of all rows. + ### `OrderingFilter` Orders results by `?ordering=field` (ascending) or `?ordering=-field` (descending). Multiple fields can be comma-separated. @@ -84,7 +86,7 @@ Orders results by `?ordering=field` (ascending) or `?ordering=-field` (descendin GET /articles?ordering=-created_at,title ``` -Only fields listed in `ordering` are allowed; unknown fields are silently skipped. +Only fields listed in `ordering` are allowed; unknown fields are silently skipped. **If `ordering` is not configured (the list is empty or omitted), the `OrderingFilter` backend ignores all `?ordering=` parameters entirely** — no ordering is applied. This prevents clients from ordering by arbitrary columns when no whitelist has been declared. ## Combining Backends diff --git a/docs/api-reference/core.md b/docs/api-reference/core.md index be86f74..00afd28 100644 --- a/docs/api-reference/core.md +++ b/docs/api-reference/core.md @@ -40,7 +40,7 @@ LightApi( | `auth_path` | `str` | Base path for the auto-registered login route. Defaults to `/auth` → `/auth/login` and `/auth/token`. | | `session_manager` | `SessionManager \| None` | Override the default session manager (advanced; mostly used for test isolation). | | `rate_limiter` | `RateLimiter \| dict \| None` | Rate-limit config applied to `/auth/login`. Pass a `RateLimiter` instance or a `{"requests_per_minute": N, ...}` dict. | -| `login_validator` | `Callable[[str, str], dict \| None]` | Credential validator used by `/auth/login`. Receives `(username, password)`, returns the JWT payload dict on success or `None` to reject. | +| `login_validator` | `Callable[[str, str], dict \| None]` | Callable `(username, password) → dict \| None`. Returns a payload dict on success, or `None` to reject. Raising an exception is treated the same as returning `None` — the client receives 401 and the exception is logged at WARNING level. | | `use_test_isolation` | `bool` | When `True`, mounts each registered endpoint onto a unique table name and a per-thread metadata/registry — used by the test suite. | ### `register(mapping)` @@ -100,6 +100,8 @@ app = LightApi.from_dict({ }) ``` +The `methods` key in each endpoint config accepts a list of HTTP verbs (`["GET", "POST"]`, etc.) and is enforced — unlisted methods return `405 Method Not Allowed`. + Programmatic alternative to YAML for simple configurations. ```yaml diff --git a/docs/api-reference/filters.md b/docs/api-reference/filters.md index 699e70e..73a5b42 100644 --- a/docs/api-reference/filters.md +++ b/docs/api-reference/filters.md @@ -66,6 +66,8 @@ GET /articles?search=async+python # WHERE title ILIKE '%async python%' OR body ILIKE '%async python%' ``` +**Search input is treated as a literal string.** The characters `%` and `_` (SQL LIKE wildcards) are automatically escaped, so `?search=hello_world` matches only the exact substring `hello_world` — not any single character in place of the underscore. Bare `%` or `_` characters in search terms do not become wildcards. + **Class:** `lightapi.filters.SearchFilter` ### `OrderingFilter` @@ -76,7 +78,7 @@ Applies `ORDER BY col ASC` or `ORDER BY col DESC` (prefix `-`) via `?ordering=`. GET /articles?ordering=-created_at,title ``` -Multiple fields can be comma-separated. Only fields listed in `ordering` are allowed; unknown fields are silently ignored. +Multiple fields can be comma-separated. Only fields explicitly listed in `ordering` are allowed; unknown field names are silently ignored. **When `ordering` is not configured (empty or omitted), the backend ignores all `?ordering=` parameters** — no ordering is applied. This prevents clients from sorting by arbitrary columns when no whitelist has been declared. **Class:** `lightapi.filters.OrderingFilter` diff --git a/docs/api-reference/rest.md b/docs/api-reference/rest.md index 40280e3..7ec2f9d 100644 --- a/docs/api-reference/rest.md +++ b/docs/api-reference/rest.md @@ -100,6 +100,19 @@ Override these methods to customise sync behaviour. Used when a sync engine is p | `update` | `(self, data, pk, partial)` | Optimistic-lock `UPDATE` | | `destroy` | `(self, request, pk)` | `DELETE WHERE id=pk` | +### PATCH and Optional fields + +When `partial=True` (a `PATCH` request), only fields present in the request body are updated. Sending an explicit `null` value for a nullable (`Optional[...]`) field clears it to `NULL` in the database: + +```python +# subtitle is Optional[str] — sending null clears the column +PATCH /products/42 +{"subtitle": null, "version": 3} +# → 200 {"id": 42, "subtitle": null, ...} +``` + +Sending `null` for a non-nullable (required) field is silently ignored — the field retains its current database value. + --- ## Async CRUD Helpers diff --git a/lightapi/_dict_config_loader.py b/lightapi/_dict_config_loader.py index 9c59805..b904bc4 100644 --- a/lightapi/_dict_config_loader.py +++ b/lightapi/_dict_config_loader.py @@ -71,12 +71,15 @@ def load_from_dict( # Build class attributes class_attrs: dict[str, Any] = {} - # Add fields + # Add fields — __annotations__ is required for RestEndpointMeta to + # detect field names; values must be Field() instances (or absent). + annotations: dict[str, Any] = {} for field_name, field_type in fields.items(): if isinstance(field_type, type): - class_attrs[field_name] = field_type() + annotations[field_name] = field_type else: - class_attrs[field_name] = field_type + annotations[field_name] = type(field_type) + class_attrs["__annotations__"] = annotations # Build Meta class meta_attrs: dict[str, Any] = {} @@ -109,38 +112,32 @@ def load_from_dict( elif isinstance(filters, list): meta_attrs["filtering"] = Filtering(backends=filters) + Meta = type("Meta", (), meta_attrs) + class_attrs["Meta"] = Meta + + # Resolve HttpMethod mixin bases so _allowed_methods is set correctly. + # Setting class_attrs["__bases__"] has no effect on type(); the bases + # must be passed as the second argument to type(). + http_bases: list[type] = [] if methods: from lightapi.methods import HttpMethod - bases = [] + _method_map = { + "GET": HttpMethod.GET, + "POST": HttpMethod.POST, + "PUT": HttpMethod.PUT, + "PATCH": HttpMethod.PATCH, + "DELETE": HttpMethod.DELETE, + } for method in methods: - if method.upper() == "GET": - bases.append(HttpMethod.GET) - elif method.upper() == "POST": - bases.append(HttpMethod.POST) - elif method.upper() == "PUT": - bases.append(HttpMethod.PUT) - elif method.upper() == "PATCH": - bases.append(HttpMethod.PATCH) - elif method.upper() == "DELETE": - bases.append(HttpMethod.DELETE) - - if bases: - Meta = type("Meta", (), meta_attrs) - class_attrs["Meta"] = Meta - - class_attrs["__bases__"] = tuple(bases) - else: - Meta = type("Meta", (), meta_attrs) - class_attrs["Meta"] = Meta - else: - if meta_attrs: - Meta = type("Meta", (), meta_attrs) - class_attrs["Meta"] = Meta - - # Create endpoint class + mixin = _method_map.get(method.upper()) + if mixin is not None: + http_bases.append(mixin) + + # Create endpoint class — include HttpMethod mixins in bases so that + # RestEndpointMeta._process picks them up via the MRO scan. class_name = _route_to_class_name(route) - endpoint_cls = type(class_name, (RestEndpoint,), class_attrs) + endpoint_cls = type(class_name, (RestEndpoint, *http_bases), class_attrs) mapping[route] = endpoint_cls diff --git a/lightapi/_login.py b/lightapi/_login.py index dbf2617..afbedc2 100644 --- a/lightapi/_login.py +++ b/lightapi/_login.py @@ -91,8 +91,8 @@ async def login_handler( """ Handle POST /auth/login and POST /auth/token. - Returns 422 for body validation, 401 for malformed Basic or invalid credentials, - 500 for validator exception, 200 with token+user (JWT) or user only (Basic). + Returns 422 for body validation, 401 for malformed Basic, invalid credentials, + or any exception raised by the validator; 200 with token+user (JWT) or user (Basic). """ # Apply rate limiting if a rate limiter is provided if rate_limiter is not None: @@ -129,10 +129,10 @@ async def login_handler( try: payload = auth_backend.validate_credentials(username, password) except Exception as e: - logger.exception("validate_credentials raised: %s", e) + logger.warning("validate_credentials raised: %s", e) return JSONResponse( - {RESPONSE_KEY_DETAIL: "Internal server error"}, - status_code=500, + {RESPONSE_KEY_DETAIL: "Invalid credentials"}, + status_code=HTTPStatus.UNAUTHORIZED, ) if payload is None: diff --git a/lightapi/filters.py b/lightapi/filters.py index 8a3c9d8..7b93075 100644 --- a/lightapi/filters.py +++ b/lightapi/filters.py @@ -6,6 +6,16 @@ from starlette.requests import Request _RESERVED_PARAMS = frozenset({"page", "page_size", "cursor", "ordering"}) +_LIKE_ESCAPE_CHAR = "\\" + + +def _escape_like(value: str) -> str: + """Escape LIKE special characters so user input is treated as a literal string.""" + return ( + value.replace(_LIKE_ESCAPE_CHAR, _LIKE_ESCAPE_CHAR * 2) + .replace("%", _LIKE_ESCAPE_CHAR + "%") + .replace("_", _LIKE_ESCAPE_CHAR + "_") + ) class BaseFilter: @@ -81,7 +91,9 @@ def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: for field in search_fields: col = getattr(cls._model_class, field, None) if col is not None: - clauses.append(col.ilike(f"%{query}%")) + clauses.append( + col.ilike(f"%{_escape_like(query)}%", escape=_LIKE_ESCAPE_CHAR) + ) if clauses: queryset = queryset.where(or_(*clauses)) return queryset @@ -98,12 +110,16 @@ def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: filtering_cfg = getattr(view, "_meta", {}).get("filtering") allowed: list[str] = (filtering_cfg.ordering or []) if filtering_cfg else [] + # No whitelist configured → ordering is disabled entirely. + if not allowed: + return queryset + cls = type(view) for field in ordering_param.split(","): field = field.strip() direction = desc if field.startswith("-") else asc field_name = field.lstrip("-") - if allowed and field_name not in allowed: + if field_name not in allowed: continue col = getattr(cls._model_class, field_name, None) if col is not None: diff --git a/lightapi/rest.py b/lightapi/rest.py index 92ca890..154ff8e 100644 --- a/lightapi/rest.py +++ b/lightapi/rest.py @@ -24,9 +24,7 @@ delete, update, ) -from sqlalchemy import ( - select as sa_select, -) +from sqlalchemy import select as sa_select from sqlalchemy.dialects.postgresql import UUID as PG_UUID try: @@ -228,9 +226,9 @@ def _process(cls: type, name: str, namespace: dict[str, Any]) -> None: serialiser_normalised = normalise_serializer(raw_serializer) cls._meta = { # type: ignore[attr-defined] - "authentication": getattr(meta_obj, "authentication", None) - if meta_obj - else None, + "authentication": ( + getattr(meta_obj, "authentication", None) if meta_obj else None + ), "filtering": getattr(meta_obj, "filtering", None) if meta_obj else None, "pagination": getattr(meta_obj, "pagination", None) if meta_obj else None, "serializer_normalised": serialiser_normalised, @@ -269,8 +267,6 @@ def _process(cls: type, name: str, namespace: dict[str, Any]) -> None: cls._test_isolation_table_name = table_name # type: ignore[attr-defined] - - class RestEndpoint(metaclass=RestEndpointMeta): """Base class for all LightAPI endpoints. @@ -482,10 +478,19 @@ def update(self, data: dict[str, Any], pk: int, partial: bool = False) -> Respon **patch_fields, ) validated = PatchSchema.model_validate(data) + # Determine which columns are nullable so explicit null values + # can clear Optional fields (non-nullable fields still skip None). + from sqlalchemy import inspect as _sa_inspect + + nullable_cols: set[str] = { + attr.key + for attr in _sa_inspect(cls._model_class).mapper.column_attrs + if any(c.nullable for c in attr.columns) + } update_data = { k: v for k, v in validated.model_dump(exclude_unset=True).items() - if k not in _AUTO_FIELDS and v is not None + if k not in _AUTO_FIELDS and (v is not None or k in nullable_cols) } else: validated = cls.__schema_create__.model_validate(data) @@ -724,10 +729,17 @@ async def _update_async( **patch_fields, ) validated = PatchSchema.model_validate(data) + from sqlalchemy import inspect as _sa_inspect + + nullable_cols: set[str] = { + attr.key + for attr in _sa_inspect(cls._model_class).mapper.column_attrs + if any(c.nullable for c in attr.columns) + } update_data = { k: v for k, v in validated.model_dump(exclude_unset=True).items() - if k not in _AUTO_FIELDS and v is not None + if k not in _AUTO_FIELDS and (v is not None or k in nullable_cols) } else: validated = cls.__schema_create__.model_validate(data) diff --git a/tests/test_login_auth.py b/tests/test_login_auth.py index d911659..4283de7 100644 --- a/tests/test_login_auth.py +++ b/tests/test_login_auth.py @@ -385,8 +385,8 @@ def test_jwt_token_usable_on_protected_endpoint(self, jwt_client): class TestValidatorException: - def test_login_validator_exception_returns_500(self): - """Validator raising propagates to 500 (Starlette default handler).""" + def test_login_validator_exception_returns_401(self): + """Validator raising any exception returns 401, not 500.""" def failing_validator(username: str, password: str): raise RuntimeError("DB unavailable") @@ -404,7 +404,8 @@ def failing_validator(username: str, password: str): "/auth/login", json={"username": "alice", "password": "secret"}, ) - assert resp.status_code == 500 + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid credentials" class TestLoginRequestModel: From b8c803f91dfd7054028521f99020ecacaa104ce0 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 29 May 2026 23:53:52 +0000 Subject: [PATCH 06/15] Bump version to 0.1.24 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 610bcbb..2667c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "lightapi" -version = "0.1.23" +version = "0.1.24" description = "A lightweight framework for building API endpoints using Python's native libraries." readme = "README.md" requires-python = ">=3.10" From 88b3686ff0301abceda148a0d0bf149423f458fb Mon Sep 17 00:00:00 2001 From: iklo Date: Fri, 29 May 2026 21:19:06 -0300 Subject: [PATCH 07/15] fix(yaml): six bugs in YAML config loader and auth checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed: - yaml_loader: Ellipsis class attributes blocked SQLAlchemy instrumentation. Fields with no constraints (no Field() in class_attrs) were stored as Ellipsis, which SA's map_imperatively silently skips — the column existed in _all_columns but never appeared in the mapper, causing NOT NULL constraint failures on INSERT. Fixed in FieldInfoStripper.strip() (table_mapping.py): also remove Ellipsis attributes so SA can install InstrumentedAttribute freely. - yaml_loader: YAML 'default' value was silently dropped. 'default' is not in constraint_keys, so filtering pydantic_kwargs discarded it. pydantic_kwargs.pop("default", ...) then returned the sentinel, making every YAML-defined default invisible to Pydantic. Fixed: extract default from the raw extra dict before filtering. - lightapi.py build_app(): CORS middleware was only applied in run(), not build_app(). TestClient and embedded-app users never got CORS headers. Fixed: apply StarletteCORSMiddleware in build_app() too. - auth_checker.py: AllowAny permission (non-dict) did not bypass the backend authentication check. An endpoint with permission=AllowAny but a backend from defaults still required a valid token. Fixed: return None before the backend check when permission_cls is AllowAny and is not a per-method dict. permission_cls=None (unset) still triggers auth when a backend is configured. - yaml_loader: dict-style methods ({GET: ..., POST: ...}) did not restrict HTTP verbs — _resolve_methods_bases returned (RestEndpoint,) for any dict, allowing all five methods. Fixed: extract dict keys as the method list and resolve HttpMethod mixins. - yaml_loader: MetaConfig had no 'table' field, so meta.table in YAML was silently ignored. Reflected endpoints could not name their table. Fixed: add table field to MetaConfig and pass it through _build_meta_class. --- lightapi/auth_checker.py | 8 +++++++- lightapi/lightapi.py | 23 +++++++++++++++-------- lightapi/table_mapping.py | 6 +++++- lightapi/yaml_loader.py | 34 +++++++++++++++++++++++++--------- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/lightapi/auth_checker.py b/lightapi/auth_checker.py index 3f28a2c..f4a5a96 100644 --- a/lightapi/auth_checker.py +++ b/lightapi/auth_checker.py @@ -1,6 +1,5 @@ """Authentication and authorization checker.""" - from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -28,6 +27,13 @@ def check_auth(cls: type, request: Request) -> Response | None: else: perm_cls = AllowAny + # Explicitly setting AllowAny (non-dict) means the endpoint is fully public — + # skip the backend authentication check entirely. + # Note: permission_cls=None defaults to AllowAny but still requires auth + # if a backend is set; only an explicit AllowAny bypasses the backend. + if not is_per_method and permission_cls is AllowAny: + return None + requires_auth = backend is not None and ( not is_per_method or perm_cls is not AllowAny ) diff --git a/lightapi/lightapi.py b/lightapi/lightapi.py index dd26c72..67d9123 100644 --- a/lightapi/lightapi.py +++ b/lightapi/lightapi.py @@ -21,10 +21,7 @@ from lightapi.authentication import AllowAny, BasicAuthentication, JWTAuthentication from lightapi.cache import get_cached, invalidate_cache_prefix, set_cached -from lightapi.constants import ( - RESPONSE_KEY_DETAIL, - HTTPStatus, -) +from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus from lightapi.exceptions import ConfigurationError from lightapi.rest import RestEndpoint from lightapi.session_manager import SessionManager @@ -606,7 +603,16 @@ def build_app(self) -> Starlette: self._create_tables() self._check_cache_connections() on_startup = [self._create_tables] if self._mode == "async" else [] - return Starlette(routes=self._routes, on_startup=on_startup) + app = Starlette(routes=self._routes, on_startup=on_startup) + if self._cors_origins: + app.add_middleware( + StarletteCORSMiddleware, + allow_origins=self._cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + return app # ───────────────────────────────────────────────────────────────────────── # YAML factory @@ -764,14 +770,15 @@ def _check_auth( perm_cls = permission_cls.get(request.method) if perm_cls is None: perm_cls = AllowAny - # Per-method AllowAny: method is public, skip backend - if perm_cls is AllowAny: - return None else: perm_cls = permission_cls else: perm_cls = AllowAny + # AllowAny means the endpoint is fully public — skip backend authentication. + if perm_cls is AllowAny: + return None + if backend is not None: # Create authenticator with config based on backend type # Note: We need to access self._login_validator from the LightApi instance diff --git a/lightapi/table_mapping.py b/lightapi/table_mapping.py index 262e3e2..efc924b 100644 --- a/lightapi/table_mapping.py +++ b/lightapi/table_mapping.py @@ -24,7 +24,11 @@ def strip(cls: type, columns: list[Column]) -> None: for col in columns: existing = cls.__dict__.get(col.name) - if isinstance(existing, FieldInfo): + # Remove FieldInfo objects AND bare Ellipsis sentinels. Both block + # SQLAlchemy's instrumentation from replacing the class attribute + # with an InstrumentedAttribute, which silently drops the column + # from the mapper. + if isinstance(existing, FieldInfo) or existing is ...: try: delattr(cls, col.name) except AttributeError: diff --git a/lightapi/yaml_loader.py b/lightapi/yaml_loader.py index 9e85db1..dc3da4b 100644 --- a/lightapi/yaml_loader.py +++ b/lightapi/yaml_loader.py @@ -51,11 +51,7 @@ def _build_name_registry() -> dict[str, type]: JWTAuthentication, ) from lightapi.core import AuthenticationMiddleware, CORSMiddleware, Middleware - from lightapi.filters import ( - FieldFilter, - OrderingFilter, - SearchFilter, - ) + from lightapi.filters import FieldFilter, OrderingFilter, SearchFilter from lightapi.methods import HttpMethod return { @@ -247,6 +243,7 @@ class MetaConfig(BaseModel): authentication: AuthConfig | None = None filtering: FilteringConfig | None = None pagination: PaginationConfig | None = None + table: str | None = None # custom table name (required when reflect: true) class FieldSpec(BaseModel): @@ -418,6 +415,8 @@ def _build_meta_class( if reflect: attrs["reflect"] = True + if meta.table: + attrs["table"] = meta.table return type("Meta", (), attrs) # Authentication — handle per-method dict (methods as dict form) @@ -512,15 +511,23 @@ def _build_meta_class( def _resolve_methods_bases(meta: MetaConfig) -> tuple[type, ...]: - """Return HttpMethod mixin bases from a methods list (ignored for dict form).""" + """Return HttpMethod mixin bases for both list and dict forms of ``methods``.""" from lightapi.rest import RestEndpoint - if not isinstance(meta.methods, list) or not meta.methods: + if isinstance(meta.methods, dict): + # Dict form: {GET: {auth: ...}, POST: {auth: ...}} — keys are the allowed verbs. + method_list = list(meta.methods.keys()) + elif isinstance(meta.methods, list): + method_list = meta.methods + else: + return (RestEndpoint,) + + if not method_list: return (RestEndpoint,) registry = _build_name_registry() bases: list[type] = [RestEndpoint] - for m in meta.methods: + for m in method_list: mixin = registry.get(m) if mixin is None: raise ConfigurationError(f"Unknown HTTP method '{m}' in methods list.") @@ -564,7 +571,16 @@ def _build_endpoint_class(entry: EndpointConfig, defaults: DefaultsConfig) -> ty "exclude", } pydantic_kwargs = {k: v for k, v in extra.items() if k in constraint_keys} - default = pydantic_kwargs.pop("default", ... if not spec.optional else None) + # Extract 'default' from the raw extra dict, not from pydantic_kwargs. + # 'default' is not in constraint_keys, so filtering would silently drop it. + _UNSET = object() + raw_default = extra.get("default", _UNSET) + if raw_default is not _UNSET: + default = raw_default + elif spec.optional: + default = None + else: + default = ... # sentinel: no default provided annotations[field_name] = py_type if pydantic_kwargs or default is not ...: From 44dfa2383bc61ea803f61d6d9074ae0b2a48a112 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 30 May 2026 00:20:32 +0000 Subject: [PATCH 08/15] Bump version to 0.1.25 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2667c1c..58c6658 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "lightapi" -version = "0.1.24" +version = "0.1.25" description = "A lightweight framework for building API endpoints using Python's native libraries." readme = "README.md" requires-python = ">=3.10" From 6d3cfddfd474a2fb2c093072caee05b9d000f67f Mon Sep 17 00:00:00 2001 From: iklo Date: Fri, 29 May 2026 21:30:25 -0300 Subject: [PATCH 09/15] feat(yaml): add cache, serializer, and mode to YAML configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - meta.cache: { ttl: N } — maps to Meta.cache = Cache(ttl=N); endpoint GET responses are cached in Redis when available, silently skipped if not. - meta.serializer: { fields: [...] } or { read: [...], write: [...] } — maps to Meta.serializer = Serializer(...); controls which fields appear in responses, globally or per HTTP verb. - Top-level mode: "sync" | "async" — passed to LightApi(mode=...) so async engines can be declared entirely from YAML without a Python override. All three features are wired through _build_meta_class and load_config. --- lightapi/yaml_loader.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lightapi/yaml_loader.py b/lightapi/yaml_loader.py index dc3da4b..c54e23e 100644 --- a/lightapi/yaml_loader.py +++ b/lightapi/yaml_loader.py @@ -235,6 +235,23 @@ class MethodAuthConfig(BaseModel): authentication: AuthConfig | None = None +class CacheConfig(BaseModel): + """Cache block inside meta: cache: { ttl: 60 }""" + + ttl: int = 60 + + +class SerializerConfig(BaseModel): + """Serializer block inside meta. + + Use ``fields`` for a unified list, or ``read``/``write`` for per-verb lists. + """ + + fields: list[str] | None = None + read: list[str] | None = None + write: list[str] | None = None + + class MetaConfig(BaseModel): """meta: block inside a declarative endpoint entry.""" @@ -243,6 +260,8 @@ class MetaConfig(BaseModel): authentication: AuthConfig | None = None filtering: FilteringConfig | None = None pagination: PaginationConfig | None = None + cache: CacheConfig | None = None + serializer: SerializerConfig | None = None table: str | None = None # custom table name (required when reflect: true) @@ -292,6 +311,7 @@ class LightAPIConfig(BaseModel): endpoints: list[EndpointConfig] = [] middleware: list[str] = [] auth: AuthLoginConfig | None = None + mode: str | None = None # "sync" | "async" — auto-detected when omitted @property def effective_database_url(self) -> str | None: @@ -507,6 +527,26 @@ def _build_meta_class( if pagination is not None: attrs["pagination"] = pagination + # Cache + if meta.cache is not None: + from lightapi.config import Cache + + attrs["cache"] = Cache(ttl=meta.cache.ttl) + + # Serializer + if meta.serializer is not None: + from lightapi.config import Serializer + + ser_cfg = meta.serializer + attrs["serializer"] = Serializer( + fields=ser_cfg.fields, + read=ser_cfg.read, + write=ser_cfg.write, + ) + + if meta.table: + attrs["table"] = meta.table + return type("Meta", (), attrs) @@ -636,6 +676,9 @@ def load_config(app_cls: type, config_path: str, **overrides: Any) -> Any: "middlewares": middlewares or None, } + if cfg.mode is not None: + constructor_kwargs["mode"] = cfg.mode + # Auth/login config from YAML auth: block if cfg.auth: constructor_kwargs["auth_path"] = cfg.auth.auth_path From ed6b7c72b01f28f189c6d00ee87c7361426bbcc4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 30 May 2026 00:31:29 +0000 Subject: [PATCH 10/15] Bump version to 0.1.26 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 58c6658..01fe874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "lightapi" -version = "0.1.25" +version = "0.1.26" description = "A lightweight framework for building API endpoints using Python's native libraries." readme = "README.md" requires-python = ">=3.10" From b667b9ae73041f722ba6aef4e997251418a63b28 Mon Sep 17 00:00:00 2001 From: iklo Date: Fri, 29 May 2026 22:11:34 -0300 Subject: [PATCH 11/15] docs: document cache/serializer/mode YAML support and fix stale claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add meta.cache, meta.serializer, mode, meta.table to YAML schema references in README, configuration guide, and YAML examples - Add full-featured YAML example (per-method auth + cache + serializer + cursor pagination + reflection) to configuration guide - Rewrite examples/yaml-configuration.md to cover every supported key - Fix incorrect 'cache is not auto-invalidated' claim in caching.md — writes DO invalidate the endpoint's cache prefix - Add YAML configuration sections to filtering.md and pagination.md - Fix single-arg Middleware.process() signature in configuration.md to the correct (request, response) two-phase form - Document AllowAny bypassing the auth backend, and login_validator exceptions mapping to 401 All 39 YAML blocks parse, all 41 Python blocks compile, 324 tests pass. --- README.md | 30 ++ docs/advanced/caching.md | 47 +- docs/advanced/filtering.md | 51 ++ docs/advanced/pagination.md | 60 +++ docs/api-reference/auth.md | 18 + docs/examples/yaml-configuration.md | 683 +++++++++++++++++++++++--- docs/getting-started/configuration.md | 126 ++++- docs/getting-started/quickstart.md | 7 + 8 files changed, 961 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 4b33689..a6b1e85 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,8 @@ database: cors_origins: - "https://myapp.com" +mode: sync # or "async" for an async engine — auto-detected when omitted + # Global defaults applied to every endpoint unless overridden defaults: authentication: @@ -616,6 +618,20 @@ endpoints: # Override the global default for this endpoint only authentication: permission: AllowAny + + - route: /products + fields: + name: { type: str, min_length: 1 } + price: { type: float, ge: 0 } + meta: + methods: [GET, POST, PUT, PATCH, DELETE] + authentication: + permission: AllowAny + cache: + ttl: 60 # cache GET responses for 60 s; invalidated on writes + serializer: + read: [id, name, version] # GET hides price + write: [id, name, price, version] # POST/PUT shows price ``` ```python @@ -640,6 +656,10 @@ app.run() | `endpoints[].meta.authentication` | object | Overrides `defaults.authentication` for this endpoint. | | `endpoints[].meta.filtering` | object | `fields`, `search`, `ordering` lists. | | `endpoints[].meta.pagination` | object | `style` + `page_size` for this endpoint. | +| `mode` | `"sync"` or `"async"` | Engine mode. Auto-detected when omitted. | +| `endpoints[].meta.cache` | object | `{ ttl: N }` — cache GET responses for N seconds (requires Redis). | +| `endpoints[].meta.serializer` | object | `{ fields: [...] }` or `{ read: [...], write: [...] }` — field projection. | +| `endpoints[].meta.table` | string | Custom table name (required when using `reflect: true`). | | `endpoints[].reflect` | bool | Reflect an existing table — no fields needed. | Validation is performed by Pydantic v2 at load time. Any schema error raises a @@ -947,6 +967,16 @@ class MyEndpoint(RestEndpoint): table = "custom_table_name" # overrides derived name ``` +| Attribute | Type | Description | +|---|---|---| +| `authentication` | `Authentication` | Backend and permission class for this endpoint. | +| `filtering` | `Filtering` | Filter backends, fields, search, and ordering lists. | +| `pagination` | `Pagination` | Pagination style and page size. | +| `serializer` | `Serializer` | Field projection for reads and/or writes. | +| `cache` | `Cache` | `Cache(ttl=N)` — cache GET responses for N seconds (requires Redis). | +| `reflect` | `bool \| "partial"` | Reflect an existing table (`True`) or extend it (`"partial"`). | +| `table` | `str` | Override the inferred table name (default: `f"{ClassName.lower()}s"`). | + ### Error responses | Scenario | Status code | Body | diff --git a/docs/advanced/caching.md b/docs/advanced/caching.md index 4032466..7a52ead 100644 --- a/docs/advanced/caching.md +++ b/docs/advanced/caching.md @@ -26,6 +26,29 @@ Install Redis and start it locally: redis-server ``` +## YAML configuration + +Set `meta.cache` in a `lightapi.yaml` file to enable caching without writing Python: + +```yaml +endpoints: + - route: /products + fields: + name: { type: str } + price: { type: float, ge: 0 } + meta: + methods: [GET, POST, PUT, PATCH, DELETE] + cache: + ttl: 300 # cache GET responses for 5 minutes; writes auto-invalidate +``` + +```python +from lightapi import LightApi + +app = LightApi.from_config("lightapi.yaml") +app.run() +``` + ## `Cache` constructor ```python @@ -76,10 +99,16 @@ class ArticleEndpoint(RestEndpoint): ## Cache invalidation -The cache is **read-only by the framework** — write operations (`POST`, `PUT`, `PATCH`, `DELETE`) do not automatically invalidate cached entries. For production workloads, either: +Write operations (`POST`, `PUT`, `PATCH`, `DELETE`) automatically invalidate all cached entries for that endpoint's key prefix. No manual invalidation is needed for the common case. + +```bash +GET /posts?published=true # cache miss → stored for 120 s (X-Cache: MISS) +GET /posts?published=true # cache hit → returned instantly (X-Cache: HIT) +POST /posts # write → all /posts cache keys invalidated +GET /posts?published=true # cache miss again → re-fetched from DB +``` -- Set a short `ttl` appropriate for your stale-tolerance -- Invalidate manually via Redis `DEL` or key patterns +If you need finer-grained invalidation (e.g. invalidate only on specific fields changing), implement a custom `post()`/`put()` override that calls `invalidate_cache_prefix(...)` directly. ## Example with filtering and pagination @@ -109,6 +138,8 @@ GET /posts?published=true&page=1 # cache hit → instant return GET /posts?published=true&page=2 # different key → cache miss ``` +> **Tip:** Include all query parameters that affect the response in `vary_on`. If `vary_on` is omitted, the full query string is part of the cache key — each unique URL is cached separately. + ## Custom cache backend Implement `BaseCache` to use a different store: @@ -130,6 +161,16 @@ class InMemoryCache(BaseCache): Pass it to `Cache` via the `backend` parameter (if your version supports it), or use it directly inside a custom `get()` override. +## Redis connection + +Set the Redis URL via environment variable: + +```bash +export LIGHTAPI_REDIS_URL="redis://localhost:6379/0" +``` + +The default is `redis://localhost:6379/0`. A `RuntimeWarning` is emitted at startup if Redis is unreachable; caching is silently skipped for the lifetime of the process. + ## Production notes - Run Redis with persistence (`appendonly yes`) if cached data is expensive to regenerate. diff --git a/docs/advanced/filtering.md b/docs/advanced/filtering.md index ca1dffc..e8b1be9 100644 --- a/docs/advanced/filtering.md +++ b/docs/advanced/filtering.md @@ -172,3 +172,54 @@ The following query parameter names are reserved and will not be treated as fiel - `cursor` — cursor pagination - `search` — `SearchFilter` - `ordering` — `OrderingFilter` + +## YAML configuration + +All filtering options available in Python are also available in YAML. Backends are auto-selected when you provide `fields`, `search`, or `ordering` — or specify them explicitly with `backends`: + +```yaml +endpoints: + - route: /articles + fields: + title: { type: str } + category: { type: str } + price: { type: float, ge: 0, default: 0 } + meta: + methods: [GET, POST] + filtering: + # Auto-selects FieldFilter (for fields), SearchFilter (for search), + # OrderingFilter (for ordering) — no backends: key needed + fields: [category] # ?category=tech (exact match) + search: [title] # ?search=python (LIKE, literals only) + ordering: [price, title] # ?ordering=price or ?ordering=-price +``` + +To use a custom backend or control the order explicitly: + +```yaml +filtering: + backends: [FieldFilter, SearchFilter, OrderingFilter] + fields: [category] + search: [title] + ordering: [price] +``` + +### Behavior reference + +| Query parameter | Backend | Behavior | +|----------------|---------|----------| +| `?category=tech` | `FieldFilter` | Exact match on whitelisted fields. Type-coerced automatically. | +| `?search=hello` | `SearchFilter` | Case-insensitive LIKE. `%` and `_` are treated as **literals**, not wildcards. | +| `?ordering=price` | `OrderingFilter` | Ascending. `-price` = descending. Multiple fields comma-separated. | +| `?ordering=any` | `OrderingFilter` | Silently ignored if `any` is not in the `ordering` whitelist. | +| `?ordering=*` | `OrderingFilter` | **Disabled entirely** when `ordering:` list is empty or omitted. | + +### Filtering + pagination + +When both are active, `count` in the response reflects the **filtered** total, not the full table size: + +```bash +# Table has 100 rows; 23 are category=tech +GET /articles?category=tech&page=2 +# → {"count": 23, "pages": 3, "results": [...]} +``` diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index 24215a5..4a2bf35 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -152,3 +152,63 @@ If `Meta.pagination` is not set, the `GET` list endpoint returns all matching ro ``` This is fine for small datasets. For large tables, always add pagination. + +## YAML configuration + +Configure pagination directly in `lightapi.yaml`: + +```yaml +# Default pagination applied to all endpoints +defaults: + pagination: + style: page_number + page_size: 20 + +endpoints: + - route: /posts + fields: + title: { type: str } + meta: + methods: [GET, POST] + # Inherits defaults — no pagination block needed + + - route: /events + fields: + name: { type: str } + meta: + methods: [GET, POST] + pagination: + style: cursor # overrides the default for this endpoint + page_size: 50 +``` + +### Behavior notes + +- A `page` beyond the last page returns `{"count": N, "pages": M, "results": []}` — never an error. +- A malformed or expired cursor silently falls back to the first page — never a 500. +- Cursor pagination encodes the last-seen row ID; it does **not** guarantee stable ordering across concurrent inserts. Pair it with `ordering` from `FilteringConfig` if stable order matters. +- The `count` and `pages` keys in page-number responses reflect the **filtered** total when `filtering` is also configured. + +## Pagination + filtering interaction + +```python +from lightapi import RestEndpoint, Pagination, Filtering, FieldFilter, OrderingFilter + +class ProductEndpoint(RestEndpoint): + name: str + in_stock: bool + price: float + + class Meta: + pagination = Pagination(style="page_number", page_size=10) + filtering = Filtering( + backends=[FieldFilter, OrderingFilter], + fields=["in_stock"], + ordering=["price"], + ) +``` + +```bash +GET /products?in_stock=true&ordering=price&page=2 +# count = number of in_stock=true rows (not all products) +``` diff --git a/docs/api-reference/auth.md b/docs/api-reference/auth.md index fa0b427..013684f 100644 --- a/docs/api-reference/auth.md +++ b/docs/api-reference/auth.md @@ -85,6 +85,10 @@ def login_validator(username: str, password: str): app = LightApi(engine=engine, login_validator=login_validator) ``` +Returns `None` to reject a login attempt. + +Raising an exception from `login_validator` is treated identically to returning `None` — the client receives `401 Unauthorized`. The exception is logged at `WARNING` level and is never surfaced in the response body. + The returned dict becomes the JWT payload. For JWT apps the response is `{"token": "", "user": {...}}`. The login route is rate-limited — see [Rate Limiting](../advanced/rate-limiting.md). Override the base path via @@ -100,6 +104,20 @@ from lightapi import AllowAny No authentication check. All requests are allowed. This is the default when `permission=None`. +> **Important:** Setting `permission=AllowAny` (either directly or via a per-method dict) completely bypasses the authentication backend — no token is required even if `backend=JWTAuthentication` is set. This lets you make individual endpoints or individual verbs publicly readable while keeping writes protected. +> +> ```python +> class ArticleEndpoint(RestEndpoint): +> title: str +> class Meta: +> authentication = Authentication( +> backend=JWTAuthentication, +> permission={"GET": AllowAny, "POST": IsAuthenticated}, +> ) +> # GET /articles — no token needed +> # POST /articles — valid JWT required +> ``` + ### `IsAuthenticated` ```python diff --git a/docs/examples/yaml-configuration.md b/docs/examples/yaml-configuration.md index e399cf0..4704404 100644 --- a/docs/examples/yaml-configuration.md +++ b/docs/examples/yaml-configuration.md @@ -4,20 +4,20 @@ title: YAML Configuration Examples # YAML Configuration Examples -`LightApi.from_config()` loads a `lightapi.yaml` validated by Pydantic v2. +`LightApi.from_config()` loads a `lightapi.yaml` file, validates it with Pydantic v2, and builds a fully configured ASGI application — no Python endpoint classes required. -## Declarative format (recommended) - -Define endpoints, fields, and `Meta` options entirely in YAML — no Python classes needed. +## Quick start ```yaml # lightapi.yaml database: - url: "${DATABASE_URL}" # ${VAR} env-var substitution + url: "${DATABASE_URL}" cors_origins: - "https://myapp.com" +mode: async + defaults: authentication: backend: JWTAuthentication @@ -39,17 +39,19 @@ auth: endpoints: - route: /products fields: - name: { type: str, max_length: 200 } - price: { type: float } - category: { type: str } + name: { type: str, min_length: 1, max_length: 200 } + price: { type: float, gt: 0 } + sku: { type: str, unique: true, index: true } + qty: { type: int, default: 0, optional: true } active: { type: bool, default: true } meta: methods: [GET, POST, PUT, DELETE] filtering: - fields: [category, active] - ordering: [price] + fields: [active] + search: [name] + ordering: [price, name] authentication: - permission: AllowAny # override global default for this endpoint + permission: AllowAny # overrides the global IsAuthenticated for this endpoint - route: /orders fields: @@ -60,43 +62,232 @@ endpoints: ``` ```python +# main.py from lightapi import LightApi app = LightApi.from_config("lightapi.yaml") app.run() ``` -## Field types +```bash +export DATABASE_URL="postgresql+psycopg2://user:pass@localhost/shop" +python main.py +``` -| `type` value | Python type | Notes | -|-------------|-------------|-------| -| `str` | `str` | | -| `int` | `int` | | -| `float` | `float` | | -| `bool` | `bool` | | -| `datetime` | `datetime.datetime` | | -| `Decimal` | `Decimal` | | +--- + +## Database URL and environment-variable substitution -## Field constraints in YAML +Any `${VAR}` placeholder in `database.url` is resolved from the process environment at load time. A missing variable raises `ConfigurationError` immediately — the server never starts with a broken config. ```yaml -fields: - name: { type: str, min_length: 1, max_length: 200 } - price: { type: float } - sku: { type: str, unique: true, index: true } - qty: { type: int, default: 0, optional: true } +database: + url: "${DATABASE_URL}" ``` -## Per-method authentication +The substitution supports exactly the `${VAR}` form. Inline references such as `prefix_${VAR}_suffix` are not expanded — the entire value must be the placeholder. -Use the dict form of `methods` for different permissions per verb: +Supported SQLAlchemy URL schemes: + +```yaml +# SQLite (sync) +database: + url: "sqlite:///./local.db" + +# PostgreSQL (sync) +database: + url: "postgresql+psycopg2://user:pass@localhost/mydb" + +# PostgreSQL (async — requires mode: async) +database: + url: "postgresql+asyncpg://user:pass@localhost/mydb" + +# MySQL (sync) +database: + url: "mysql+pymysql://user:pass@localhost/mydb" +``` + +--- + +## mode + +Controls whether LightAPI uses sync or async SQLAlchemy sessions. When omitted, mode is auto-detected from endpoint method signatures. + +```yaml +mode: async # "sync" | "async" +``` + +`mode: async` requires an `AsyncEngine` and async dialect drivers (e.g. `asyncpg`, `aiosqlite`). Pass an engine override when the URL alone is insufficient: + +```python +from sqlalchemy.ext.asyncio import create_async_engine +from lightapi import LightApi + +engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb") +app = LightApi.from_config("lightapi.yaml", engine=engine) +app.run() +``` + +--- + +## cors_origins + +List of allowed CORS origins. Adds `CORSMiddleware` with `allow_credentials=True`, `allow_methods=["*"]`, `allow_headers=["*"]`. + +```yaml +cors_origins: + - "https://myapp.com" + - "https://staging.myapp.com" + - "http://localhost:3000" +``` + +--- + +## auth block + +Configures the login endpoint (`POST /auth/login` and `POST /auth/token`) used by `JWTAuthentication` and `BasicAuthentication`. + +```yaml +auth: + auth_path: /auth # prefix; default is /auth + login_validator: myapp.validators.validate_login +``` + +`login_validator` must be a dotted path to a **synchronous** callable that accepts exactly two required positional arguments (`username`, `password`) and returns a `dict` on success or `None` / raises an exception on failure. + +```python +# myapp/validators.py + +def validate_login(username: str, password: str) -> dict | None: + """Return a claims dict on success; raise ValueError or return None on failure.""" + user = User.query.filter_by(username=username).first() + if user is None or not user.check_password(password): + raise ValueError("Invalid credentials") + return {"sub": str(user.id), "email": user.email, "role": user.role} +``` + +Rules enforced at startup: +- Must be importable at the dotted path. +- Must be synchronous (not `async def`). +- Must have exactly 2 required positional parameters. +- Any exception raised during login → HTTP 401. + +--- + +## defaults block + +Global defaults applied to every endpoint unless that endpoint provides its own override. + +```yaml +defaults: + authentication: + backend: JWTAuthentication # JWTAuthentication | BasicAuthentication + permission: IsAuthenticated # AllowAny | IsAuthenticated | IsAdminUser + jwt_expiration: 3600 # seconds; JWT only + jwt_extra_claims: [sub, email] # additional JWT payload claims; JWT only + jwt_algorithm: HS256 # HS256/HS384/HS512/RS256/RS384/RS512/ES256/ES384/ES512 + pagination: + style: page_number # page_number | cursor + page_size: 20 +``` + +An endpoint's own `meta.authentication` or `meta.pagination` block overrides the corresponding default entirely — individual sub-keys are not merged. + +--- + +## middleware + +Class names resolved from the built-in registry or as dotted import paths. Applied to every request in order (pre) and in reverse order (post). + +```yaml +middleware: + - CORSMiddleware + - AuthenticationMiddleware + - myapp.middleware.RequestIdMiddleware # dotted path for custom middleware +``` + +Built-in middleware names: `Middleware`, `CORSMiddleware`, `AuthenticationMiddleware`. + +--- + +## Field types and constraints + +Every field in `fields:` requires a `type` key. All other keys are forwarded as constraints to the underlying `Field()` definition. + +### Supported types + +| `type` value | Python type | +|--------------|--------------------| +| `str` | `str` | +| `int` | `int` | +| `float` | `float` | +| `bool` | `bool` | +| `datetime` | `datetime.datetime` | +| `Decimal` | `Decimal` | +| `decimal` | `Decimal` | + +### Supported constraints + +| Key | Applies to | Description | +|-----------------|-------------------|--------------------------------------------------| +| `min_length` | `str` | Minimum string length. | +| `max_length` | `str` | Maximum string length. | +| `gt` | numeric | Value must be strictly greater than. | +| `ge` | numeric | Value must be greater than or equal to. | +| `lt` | numeric | Value must be strictly less than. | +| `le` | numeric | Value must be less than or equal to. | +| `default` | any | Default value when the field is omitted. | +| `optional` | any | `true` makes the field `Optional[T]` (nullable). | +| `unique` | any | Adds a UNIQUE constraint to the database column. | +| `index` | any | Adds an index to the database column. | +| `foreign_key` | `int` (usually) | SQLAlchemy foreign key (e.g. `"users.id"`). | +| `decimal_places`| `Decimal` | Number of decimal places for `Decimal` columns. | + +### Complete field example + +```yaml +endpoints: + - route: /items + fields: + name: { type: str, min_length: 1, max_length: 200 } + description: { type: str, optional: true } + price: { type: Decimal, gt: 0, decimal_places: 2 } + qty: { type: int, ge: 0, default: 0 } + weight_kg: { type: float, gt: 0, lt: 1000 } + active: { type: bool, default: true } + expires_at: { type: datetime, optional: true } + sku: { type: str, unique: true, index: true, max_length: 50 } + category_id: { type: int, foreign_key: "categories.id" } + meta: + methods: [GET, POST, PUT, PATCH, DELETE] +``` + +--- + +## meta block + +Every endpoint's behavior is controlled by its `meta:` block. + +### methods — list form + +```yaml +meta: + methods: [GET, POST, PUT, PATCH, DELETE] +``` + +All listed verbs use the same authentication setting from `meta.authentication` (or the global default). + +### methods — dict form (per-method auth) + +Use a dict when different verbs need different permissions: ```yaml endpoints: - route: /articles fields: - title: { type: str } + title: { type: str, max_length: 300 } content: { type: str } + draft: { type: bool, default: true } meta: methods: GET: @@ -106,50 +297,430 @@ endpoints: DELETE: authentication: { permission: IsAdminUser } authentication: - backend: JWTAuthentication + backend: JWTAuthentication # shared backend for all methods +``` + +Methods not listed in the dict are not registered (no route is created for them). Methods listed without an `authentication` key inherit the endpoint-level `meta.authentication`, which in turn falls back to `defaults.authentication`. + +### meta.authentication + +Override global auth for a single endpoint: + +```yaml +meta: + authentication: + backend: JWTAuthentication + permission: IsAuthenticated + jwt_expiration: 900 + jwt_extra_claims: [sub] + jwt_algorithm: HS512 +``` + +Set `permission: AllowAny` to make an endpoint fully public while keeping auth configured globally. + +### meta.filtering + +LightAPI supports three filter backends. When `backends:` is omitted, backends are **auto-selected** based on which lists are populated: + +- `fields` present → `FieldFilter` added +- `search` present → `SearchFilter` added +- `ordering` present → `OrderingFilter` added + +**Auto-backend selection (recommended):** + +```yaml +meta: + filtering: + fields: [category, active] # exact-match: ?category=books + search: [name, description] # LIKE search: ?search=laptop + ordering: [price, created_at] # sort: ?ordering=-price or ?ordering=price,name +``` + +**Explicit backend list:** + +```yaml +meta: + filtering: + backends: [FieldFilter, SearchFilter, OrderingFilter] + fields: [category, active] + search: [name] + ordering: [price] +``` + +Use the explicit `backends:` list when you need a strict subset — for example, `SearchFilter` without `FieldFilter`: + +```yaml +meta: + filtering: + backends: [SearchFilter] + search: [name, description] +``` + +### meta.pagination — page_number style + +```yaml +meta: + pagination: + style: page_number + page_size: 25 ``` -## Database table reflection +Request: `GET /products?page=2&page_size=25` + +Response shape: + +```json +{ + "count": 120, + "pages": 5, + "next": "http://localhost/products?page=3", + "previous": "http://localhost/products?page=1", + "results": [...] +} +``` + +### meta.pagination — cursor style + +```yaml +meta: + pagination: + style: cursor + page_size: 50 +``` + +Request: `GET /events?cursor=` + +Response shape: + +```json +{ + "next": "", + "previous": null, + "results": [...] +} +``` + +Cursor pagination requires an `id` column (integer primary key). The cursor token is `base64(json({"id": last_id}))`. + +### meta.cache + +Caches GET responses in Redis. Write operations (POST, PUT, PATCH, DELETE) automatically invalidate all cache entries for the endpoint. + +```yaml +meta: + cache: + ttl: 120 # seconds; must be >= 1 +``` + +The cache key includes the endpoint class name, URL path, and full query string, so filtered and paginated results are cached independently. If Redis is unreachable at startup, a `RuntimeWarning` is emitted and caching is silently skipped per request. + +### meta.serializer — unified field list + +Limit which fields appear in all responses (reads) and are accepted in request bodies (writes): + +```yaml +meta: + serializer: + fields: [id, name, price, active] +``` + +### meta.serializer — separate read and write lists + +```yaml +meta: + serializer: + read: [id, name, price, active, created_at] + write: [name, price, active] +``` + +`fields` and `read`/`write` are mutually exclusive — using both raises `ConfigurationError` at startup. + +### meta.table + +Override the database table name for an endpoint. Required when `reflect: true` and the table name differs from the default (`s` lowercased). + +```yaml +meta: + table: legacy_product_catalog +``` + +--- + +## Table reflection + +Reflect an existing database table instead of defining fields: ```yaml endpoints: - route: /legacy-orders reflect: true + meta: + table: legacy_orders_2019 # required when table name != default + methods: [GET] ``` -## Environment variable substitution +When `reflect: true` the `fields:` block is ignored. The endpoint schema is derived from the existing table columns at startup. -Any `${VAR}` placeholder is replaced at load time. A missing variable raises `ConfigurationError` immediately: +--- + +## Complete real-world example + +This single YAML file shows every supported feature together: ```yaml +# lightapi.yaml — complete example database: url: "${DATABASE_URL}" + +mode: async + +cors_origins: + - "https://myapp.com" + - "http://localhost:3000" + +defaults: + authentication: + backend: JWTAuthentication + permission: IsAuthenticated + jwt_expiration: 3600 + jwt_extra_claims: [sub, email] + jwt_algorithm: HS256 + pagination: + style: page_number + page_size: 20 + +middleware: + - CORSMiddleware + +auth: + auth_path: /auth + login_validator: myapp.validators.validate_login + +endpoints: + # Public product catalog — AllowAny overrides global IsAuthenticated + - route: /products + fields: + name: { type: str, min_length: 1, max_length: 200 } + description: { type: str, optional: true } + price: { type: Decimal, gt: 0, decimal_places: 2 } + qty: { type: int, ge: 0, default: 0 } + active: { type: bool, default: true } + sku: { type: str, unique: true, index: true } + category_id: { type: int, foreign_key: "categories.id" } + meta: + methods: [GET, POST, PUT, DELETE] + authentication: + permission: AllowAny + filtering: + fields: [active, category_id] + search: [name, description] + ordering: [price, name] + pagination: + style: page_number + page_size: 50 + cache: + ttl: 300 + serializer: + read: [id, name, price, active, sku] + write: [name, description, price, qty, active, sku, category_id] + table: product_catalog + + # Per-method auth: public reads, authenticated writes, admin-only deletes + - route: /articles + fields: + title: { type: str, max_length: 300 } + body: { type: str } + published: { type: bool, default: false } + views: { type: int, default: 0, optional: true } + created_at: { type: datetime, optional: true } + meta: + methods: + GET: + authentication: { permission: AllowAny } + POST: + authentication: { permission: IsAuthenticated } + PUT: + authentication: { permission: IsAuthenticated } + DELETE: + authentication: { permission: IsAdminUser } + authentication: + backend: JWTAuthentication + filtering: + fields: [published] + search: [title, body] + ordering: [created_at, views] + pagination: + style: cursor + page_size: 10 + + # Cursor-paginated event log with explicit backends + - route: /events + fields: + event_type: { type: str, max_length: 50 } + payload: { type: str } + occurred_at:{ type: datetime } + user_id: { type: int, foreign_key: "users.id", optional: true } + meta: + methods: [GET, POST] + filtering: + backends: [FieldFilter, OrderingFilter] + fields: [event_type, user_id] + ordering: [occurred_at] + pagination: + style: cursor + page_size: 100 + cache: + ttl: 60 + + # Legacy table — reflected from DB, no field definitions needed + - route: /legacy-invoices + reflect: true + meta: + table: invoices_archive_2020 + methods: [GET] ``` -```bash -export DATABASE_URL="postgresql+psycopg2://user:pass@localhost/mydb" -python -c "from lightapi import LightApi; LightApi.from_config('lightapi.yaml').run()" +```python +# main.py +from sqlalchemy.ext.asyncio import create_async_engine +from lightapi import LightApi + +# kwargs override YAML-derived values +engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/shop") +app = LightApi.from_config("lightapi.yaml", engine=engine) +app.run(host="0.0.0.0", port=8000) +``` + +--- + +## login_validator Python example + +```python +# myapp/validators.py +from myapp.models import User +from myapp.security import verify_password + +def validate_login(username: str, password: str) -> dict | None: + """Validate credentials and return a JWT claims dict, or raise on failure. + + Rules: + - Return a dict on success; the keys become additional JWT payload claims. + - Return None or raise any exception to trigger HTTP 401. + - Must be synchronous; async validators are rejected at startup. + - Must accept exactly 2 required positional arguments. + """ + user = User.get_by_username(username) + if user is None or not verify_password(password, user.password_hash): + raise ValueError("Invalid username or password") + if not user.is_active: + raise PermissionError("Account is disabled") + return { + "sub": str(user.id), + "email": user.email, + "role": user.role, + } +``` + +Reference in YAML: + +```yaml +auth: + auth_path: /auth + login_validator: myapp.validators.validate_login +``` + +Successful login returns: + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 3600 +} +``` + +--- + +## Behavior notes + +### SearchFilter — `%` and `_` treated as literals + +`SearchFilter` escapes SQL LIKE special characters before constructing the `ILIKE` clause. A user searching for `50%` or `file_name` matches those literal strings — there is no way to inject a wildcard through the `?search=` parameter. + +### OrderingFilter — explicit whitelist required + +`OrderingFilter` only sorts on columns explicitly listed in `filtering.ordering`. An empty or omitted `ordering:` list disables ordering entirely, even if `OrderingFilter` is listed under `backends:`. Client-supplied `?ordering=` values that name non-whitelisted columns are silently ignored. + +```yaml +filtering: + ordering: [] # ordering disabled — ?ordering= has no effect ``` +### AllowAny bypasses backend authentication + +When `permission: AllowAny` is set (endpoint-level or per-method), the authentication backend is **not called**. No token or credential is validated. Use `AllowAny` only for genuinely public endpoints. + +### PATCH `null` clears Optional fields + +A `PATCH` request with `"field": null` sets that column to `NULL` in the database, provided the field is declared `optional: true`. Fields without `optional: true` reject `null` values with a 422 validation error. + +### Cache auto-invalidated on writes + +Any non-GET request (POST, PUT, PATCH, DELETE) automatically invalidates all cache entries whose key prefix matches `lightapi::`. The cache TTL controls read freshness only; writes always clear stale data immediately. + +### login_validator exceptions → 401 + +Any exception raised inside `login_validator` — regardless of type — causes the login endpoint to return HTTP 401 with `{"detail": "Authentication credentials invalid."}`. The exception is not re-raised or logged to the client. + +--- + ## YAML schema reference -| Field | Type | Description | -|-------|------|-------------| -| `database.url` | string | SQLAlchemy URL. Supports `${VAR}` substitution. | -| `cors_origins` | list | CORS allowed origins. | -| `defaults.authentication.backend` | string | Auth backend class name (`JWTAuthentication`, `BasicAuthentication`). | -| `defaults.authentication.permission` | string | Permission class name. | -| `defaults.authentication.jwt_expiration` | int | JWT token expiration in seconds (JWT only). | -| `defaults.authentication.jwt_extra_claims` | list | Claims to include in token payload (JWT only). | -| `defaults.authentication.jwt_algorithm` | string | JWT algorithm (HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512). | -| `auth.auth_path` | string | Path prefix for `/login` and `/token` (default `/auth`). | -| `auth.login_validator` | string | Dotted path to credential validator callable (e.g. `myapp.validators.check_user`). | -| `defaults.pagination.style` | string | `page_number` or `cursor`. | -| `defaults.pagination.page_size` | int | Rows per page. | -| `middleware` | list | Class names resolved at startup. | -| `endpoints[].route` | string | URL prefix. | -| `endpoints[].fields` | object | Field definitions. | -| `endpoints[].meta.methods` | list or dict | HTTP verbs or per-method auth dict. | -| `endpoints[].meta.filtering` | object | `fields`, `search`, `ordering`. | -| `endpoints[].meta.pagination` | object | `style`, `page_size`. | -| `endpoints[].reflect` | bool | Reflect existing table. | +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `database.url` | string | — | SQLAlchemy connection URL. Supports `${VAR}` substitution. Required unless `engine=` is passed to `from_config()`. | +| `mode` | string | auto | `"sync"` or `"async"`. Auto-detected from endpoint methods when omitted. | +| `cors_origins` | list[string] | `[]` | Allowed CORS origins. | +| `middleware` | list[string] | `[]` | Built-in or dotted-path middleware class names. | +| `auth.auth_path` | string | `"/auth"` | URL prefix for `/login` and `/token`. | +| `auth.login_validator` | string | — | Dotted path to sync credential validator `(username, password) -> dict \| None`. | +| `defaults.authentication.backend` | string | — | Auth backend class name: `JWTAuthentication` or `BasicAuthentication`. | +| `defaults.authentication.permission` | string | — | Permission class: `AllowAny`, `IsAuthenticated`, `IsAdminUser`. | +| `defaults.authentication.jwt_expiration` | int | `3600` | JWT expiry in seconds. JWT only. | +| `defaults.authentication.jwt_extra_claims` | list[string] | `[]` | Additional claims included in JWT payload. JWT only. | +| `defaults.authentication.jwt_algorithm` | string | `HS256` | JWT signing algorithm. Valid: HS256/HS384/HS512/RS256/RS384/RS512/ES256/ES384/ES512. | +| `defaults.pagination.style` | string | `page_number` | `page_number` or `cursor`. | +| `defaults.pagination.page_size` | int | `20` | Rows per page. | +| `endpoints[].route` | string | — | URL prefix (e.g. `/products`). Required. | +| `endpoints[].reflect` | bool | `false` | Reflect schema from an existing database table. | +| `endpoints[].fields` | object | `{}` | Field definitions. Ignored when `reflect: true`. | +| `endpoints[].fields..type` | string | — | One of: `str`, `int`, `float`, `bool`, `datetime`, `Decimal`. Required. | +| `endpoints[].fields..optional` | bool | `false` | Makes the field `Optional[T]` (nullable). | +| `endpoints[].fields..default` | any | — | Default value when the field is omitted. | +| `endpoints[].fields..min_length` | int | — | Minimum string length. `str` only. | +| `endpoints[].fields..max_length` | int | — | Maximum string length. `str` only. | +| `endpoints[].fields..gt` | number | — | Value must be strictly greater than. Numeric types. | +| `endpoints[].fields..ge` | number | — | Value must be greater than or equal to. Numeric types. | +| `endpoints[].fields..lt` | number | — | Value must be strictly less than. Numeric types. | +| `endpoints[].fields..le` | number | — | Value must be less than or equal to. Numeric types. | +| `endpoints[].fields..unique` | bool | `false` | Adds a UNIQUE database constraint. | +| `endpoints[].fields..index` | bool | `false` | Adds a database index. | +| `endpoints[].fields..foreign_key` | string | — | Foreign key reference (e.g. `"users.id"`). | +| `endpoints[].fields..decimal_places` | int | — | Decimal precision. `Decimal` only. | +| `endpoints[].meta.methods` | list[string] or dict | `[]` | HTTP verbs as list, or dict of `{VERB: {authentication: ...}}`. | +| `endpoints[].meta.authentication.backend` | string | — | Auth backend override for this endpoint. | +| `endpoints[].meta.authentication.permission` | string | — | Permission class override for this endpoint. | +| `endpoints[].meta.authentication.jwt_expiration` | int | — | JWT expiry override for this endpoint. | +| `endpoints[].meta.authentication.jwt_extra_claims` | list[string] | — | JWT extra claims override for this endpoint. | +| `endpoints[].meta.authentication.jwt_algorithm` | string | — | JWT algorithm override for this endpoint. | +| `endpoints[].meta.filtering.backends` | list[string] | auto | Explicit filter backends: `FieldFilter`, `SearchFilter`, `OrderingFilter`. | +| `endpoints[].meta.filtering.fields` | list[string] | `[]` | Exact-match filter fields. Auto-enables `FieldFilter`. | +| `endpoints[].meta.filtering.search` | list[string] | `[]` | ILIKE search fields (`?search=`). Auto-enables `SearchFilter`. | +| `endpoints[].meta.filtering.ordering` | list[string] | `[]` | Allowed ordering fields (`?ordering=`). Auto-enables `OrderingFilter`. Empty list disables ordering. | +| `endpoints[].meta.pagination.style` | string | from defaults | `page_number` or `cursor`. | +| `endpoints[].meta.pagination.page_size` | int | from defaults | Rows per page. | +| `endpoints[].meta.cache.ttl` | int | `60` | Response cache TTL in seconds. Requires Redis. Writes auto-invalidate. | +| `endpoints[].meta.serializer.fields` | list[string] | — | Fields included in all responses and accepted in all request bodies. Mutually exclusive with `read`/`write`. | +| `endpoints[].meta.serializer.read` | list[string] | — | Fields included in GET responses only. | +| `endpoints[].meta.serializer.write` | list[string] | — | Fields accepted in POST/PUT/PATCH request bodies only. | +| `endpoints[].meta.table` | string | — | Override database table name. Required when `reflect: true` and table name differs from default. | diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index ce35400..a156bc8 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -148,6 +148,98 @@ app = LightApi.from_config("lightapi.yaml") app.run() ``` +### Full-featured YAML example + +```yaml +# lightapi.yaml — all features in one file +mode: sync # or "async" for AsyncEngine + +database: + url: "${DATABASE_URL}" + +cors_origins: + - "https://myapp.com" + +auth: + auth_path: /v1/auth + login_validator: myapp.validators.login # dotted path to sync callable + +defaults: + authentication: + backend: JWTAuthentication + permission: IsAuthenticated + jwt_expiration: 3600 + pagination: + style: page_number + page_size: 20 + +middleware: + - myapp.middleware.RequestIdMiddleware + +endpoints: + # ── Public read, authenticated write ────────────────────────────────────── + - route: /articles + fields: + title: { type: str, min_length: 1, max_length: 200 } + category: { type: str, min_length: 1 } + price: { type: float, ge: 0, default: 0 } + featured: { type: bool, default: false } + note: { type: str, optional: true } + meta: + methods: + GET: + authentication: { permission: AllowAny } + POST: + authentication: { permission: IsAuthenticated } + PUT: + authentication: { permission: IsAuthenticated } + PATCH: + authentication: { permission: IsAuthenticated } + DELETE: + authentication: { permission: IsAdminUser } + authentication: + backend: JWTAuthentication + filtering: + fields: [category, featured] + search: [title] + ordering: [price, title] + pagination: + style: page_number + page_size: 10 + cache: + ttl: 60 # GET responses cached 60 s; invalidated on writes + serializer: + read: [id, title, category, price, featured, version] + write: [id, title, category, price, featured, note, version] + + # ── Fully protected with cursor pagination ──────────────────────────────── + - route: /orders + fields: + reference: { type: str, min_length: 1 } + total: { type: float, ge: 0 } + meta: + methods: [GET, POST] + pagination: + style: cursor + page_size: 50 + + # ── Reflected legacy table ──────────────────────────────────────────────── + - route: /legacy + reflect: true + meta: + table: legacy_orders + methods: [GET] + authentication: + permission: AllowAny +``` + +```python +from lightapi import LightApi + +app = LightApi.from_config("lightapi.yaml") +app.run() +``` + ### YAML schema reference | Field | Type | Description | @@ -165,6 +257,13 @@ app.run() | `endpoints[].meta.authentication` | object | Overrides `defaults.authentication` for this endpoint. | | `endpoints[].meta.filtering` | object | `fields`, `search`, `ordering` lists. Backends are auto-selected. | | `endpoints[].meta.pagination` | object | `style` + `page_size` for this endpoint. | +| `mode` | string | `"sync"` or `"async"`. Auto-detected when omitted. | +| `endpoints[].meta.cache` | object | `{ ttl: N }` — cache GET responses for N seconds. Requires Redis; silently skipped if unreachable. Writes (POST/PUT/PATCH/DELETE) invalidate the cache. | +| `endpoints[].meta.serializer` | object | `{ fields: [...] }` for unified projection, or `{ read: [...], write: [...] }` for per-verb projection. | +| `endpoints[].meta.table` | string | Custom table name. Required when `reflect: true` to specify which existing table to map. | +| `endpoints[].meta.filtering.backends` | list | Explicit list of backend class names (`FieldFilter`, `SearchFilter`, `OrderingFilter`). Auto-selected from non-empty `fields`/`search`/`ordering` when omitted. | +| `auth.auth_path` | string | Path prefix for the auto-registered login routes (default `/auth` → `/auth/login`). | +| `auth.login_validator` | string | Dotted import path to a sync `(username, password) → dict \| None` function. Exceptions are treated as `None` (→ 401). | | `endpoints[].reflect` | bool | Reflect an existing table instead of creating one. | #### Field definition keys @@ -181,6 +280,20 @@ Each key under `endpoints[].fields` maps a column name to: | `pattern` | string | Regex pattern for strings. | | `index` | bool | Create a database index. | | `unique` | bool | Add a UNIQUE constraint. | +| `foreign_key` | string | `"table.col"` — adds a SQLAlchemy `ForeignKey` constraint. | +| `decimal_places` | int | Precision for `Decimal` columns. | + +### Cache invalidation + +When `meta.cache` is set, successful `GET` (list and detail) responses are stored in Redis. Any **write** request (`POST`, `PUT`, `PATCH`, `DELETE`) on the same endpoint automatically invalidates the cached entries for that endpoint's key prefix — no manual invalidation required. + +```yaml +meta: + cache: + ttl: 300 # GET cached for 5 minutes; auto-invalidated on writes +``` + +If Redis is unreachable at startup, a `RuntimeWarning` is emitted and caching is silently skipped for the lifetime of the process. ### Environment variable substitution @@ -254,10 +367,19 @@ Register middleware globally at startup: ```python from lightapi import LightApi, Middleware from starlette.requests import Request +from starlette.responses import Response class RequestLogMiddleware(Middleware): - def process(self, request: Request) -> None: - print(f"{request.method} {request.url.path}") + def process( + self, request: Request, response: Response | None + ) -> Response | None: + if response is None: + # pre-request phase + print(f"→ {request.method} {request.url.path}") + else: + # post-response phase + print(f"← {response.status_code} {request.url.path}") + return response app = LightApi(engine=engine, middlewares=[RequestLogMiddleware]) ``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index bde5f5e..5af1d07 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -88,6 +88,9 @@ fields and `Meta` options entirely in YAML — no Python classes needed: # lightapi.yaml database: url: "${DATABASE_URL}" # ${VAR} env-var substitution + +mode: sync # or "async" for an AsyncEngine + cors_origins: - "http://localhost:3000" @@ -107,6 +110,8 @@ endpoints: filtering: fields: [author] ordering: [year, title] + cache: + ttl: 60 # cache GET responses; invalidated on writes ``` ```python @@ -116,6 +121,8 @@ app = LightApi.from_config("lightapi.yaml") app.run() ``` +> **New YAML features:** `mode` selects sync/async engine; `meta.cache` enables Redis caching; `meta.serializer` controls which fields appear in responses. See the [Configuration Guide](configuration.md) for the complete schema. + See the [Configuration Guide](configuration.md) for the full YAML schema reference. --- From bbfe766e4c12c6b9a34ab08d7af2d4142137670e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 30 May 2026 01:14:02 +0000 Subject: [PATCH 12/15] Bump version to 0.1.27 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 01fe874..fc49f49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "lightapi" -version = "0.1.26" +version = "0.1.27" description = "A lightweight framework for building API endpoints using Python's native libraries." readme = "README.md" requires-python = ">=3.10" From 2157eafd361620e22e3c366169c5742254bf7db5 Mon Sep 17 00:00:00 2001 From: iklo Date: Sat, 1 Aug 2026 19:21:52 -0300 Subject: [PATCH 13/15] fix(yaml): map declarative fields without constraints or defaults A declarative endpoint field with no constraints and no default (e.g. `price: {type: float}`) was given an Ellipsis class attribute. Ellipsis is not a FieldInfo, so FieldInfoStripper never removed it, and the leftover class attribute stopped SQLAlchemy from mapping the column. The column was created NOT NULL by create_all but its value was silently dropped on INSERT, so every write to such a field raised a NOT NULL violation. Leave these fields annotation-only: the annotation alone already makes the generated schema field required, and the column now maps and persists. Claude-Session: https://claude.ai/code/session_01LWV5jPTwsAehx2rM7UZjtq --- lightapi/yaml_loader.py | 12 +++++------ tests/test_yaml_plain_field.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 tests/test_yaml_plain_field.py diff --git a/lightapi/yaml_loader.py b/lightapi/yaml_loader.py index 9e85db1..20bfefc 100644 --- a/lightapi/yaml_loader.py +++ b/lightapi/yaml_loader.py @@ -51,11 +51,7 @@ def _build_name_registry() -> dict[str, type]: JWTAuthentication, ) from lightapi.core import AuthenticationMiddleware, CORSMiddleware, Middleware - from lightapi.filters import ( - FieldFilter, - OrderingFilter, - SearchFilter, - ) + from lightapi.filters import FieldFilter, OrderingFilter, SearchFilter from lightapi.methods import HttpMethod return { @@ -571,8 +567,10 @@ def _build_endpoint_class(entry: EndpointConfig, defaults: DefaultsConfig) -> ty if default is not ...: pydantic_kwargs["default"] = default class_attrs[field_name] = Field(**pydantic_kwargs) - else: - class_attrs[field_name] = ... + # A constraint-less, default-less field is annotation-only. Do NOT set a + # class attribute: an Ellipsis placeholder would survive FieldInfoStripper + # (which only removes FieldInfo) and stop SQLAlchemy from mapping the + # column, so the value would silently never be inserted. # Build Meta inner class Meta = _build_meta_class(entry.meta, defaults, reflect=entry.reflect) diff --git a/tests/test_yaml_plain_field.py b/tests/test_yaml_plain_field.py new file mode 100644 index 0000000..f104453 --- /dev/null +++ b/tests/test_yaml_plain_field.py @@ -0,0 +1,38 @@ +"""Declarative YAML fields without constraints must still persist. + +Regression: a field like ``price: {type: float}`` (no constraints, no default) +used to get an Ellipsis class attribute that blocked SQLAlchemy from mapping the +column, so its value was silently dropped on INSERT. +""" + +import textwrap + +from starlette.testclient import TestClient + +from lightapi import LightApi + + +def test_constraintless_field_is_persisted(tmp_path) -> None: + config = tmp_path / "lightapi.yaml" + config.write_text(textwrap.dedent(f""" + database: + url: "sqlite:///{tmp_path / 'plain.db'}" + endpoints: + - route: /products + fields: + name: {{ type: str, max_length: 200 }} + price: {{ type: float }} + meta: + methods: [GET, POST] + authentication: {{ permission: AllowAny }} + """)) + + app = LightApi.from_config(str(config)) + client = TestClient(app.build_app()) + + resp = client.post("/products", json={"name": "Widget", "price": 9.5}) + assert resp.status_code == 201 + assert resp.json()["price"] == 9.5 + + listing = client.get("/products").json()["results"] + assert listing[0]["price"] == 9.5 From 6a9d6e1d2738322b49d6df1576c4c2cac4ddacdd Mon Sep 17 00:00:00 2001 From: iklo Date: Sat, 1 Aug 2026 19:21:56 -0300 Subject: [PATCH 14/15] feat: package LightAPI as a Helm chart for zero-CRUD-code deploys Deploy a declarative CRUD API over an existing database with only a values.yaml: the chart hands LightApi.from_config the declarative config and runs it, so teams write no CRUD code. - ServerCommand (lightapi/server.py) + `lightapi serve` console script + `python -m lightapi`: boot a server from LIGHTAPI_CONFIG/HOST/PORT. - Always-on /healthz route (lightapi/health.py) for orchestrator probes, wired once into both run() and build_app(). - Generic Dockerfile: one image, config injected at deploy time. - charts/lightapi: Deployment (config via ConfigMap, secrets via Secret, /healthz liveness+readiness probes), Service, Secret, ServiceAccount, optional Ingress and HPA. Verified end to end on minikube: helm install/upgrade/rollback, config-change pod roll, secret and existingSecret paths, HPA, method restriction, optimistic locking, and broken-config CrashLoopBackOff with no traffic served. Claude-Session: https://claude.ai/code/session_01LWV5jPTwsAehx2rM7UZjtq --- .dockerignore | 20 +++ Dockerfile | 19 ++ charts/lightapi/Chart.yaml | 12 ++ charts/lightapi/templates/NOTES.txt | 13 ++ charts/lightapi/templates/_helpers.tpl | 35 ++++ charts/lightapi/templates/configmap.yaml | 9 + charts/lightapi/templates/deployment.yaml | 76 ++++++++ charts/lightapi/templates/hpa.yaml | 22 +++ charts/lightapi/templates/ingress.yaml | 31 ++++ charts/lightapi/templates/secret.yaml | 18 ++ charts/lightapi/templates/service.yaml | 15 ++ charts/lightapi/templates/serviceaccount.yaml | 6 + charts/lightapi/values.yaml | 88 +++++++++ docs/deployment/helm-chart-plan.md | 167 ++++++++++++++++++ lightapi/__main__.py | 6 + lightapi/health.py | 21 +++ lightapi/lightapi.py | 17 +- lightapi/server.py | 68 +++++++ pyproject.toml | 3 + tests/test_health.py | 28 +++ tests/test_server_command.py | 34 ++++ 21 files changed, 702 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 charts/lightapi/Chart.yaml create mode 100644 charts/lightapi/templates/NOTES.txt create mode 100644 charts/lightapi/templates/_helpers.tpl create mode 100644 charts/lightapi/templates/configmap.yaml create mode 100644 charts/lightapi/templates/deployment.yaml create mode 100644 charts/lightapi/templates/hpa.yaml create mode 100644 charts/lightapi/templates/ingress.yaml create mode 100644 charts/lightapi/templates/secret.yaml create mode 100644 charts/lightapi/templates/service.yaml create mode 100644 charts/lightapi/templates/serviceaccount.yaml create mode 100644 charts/lightapi/values.yaml create mode 100644 docs/deployment/helm-chart-plan.md create mode 100644 lightapi/__main__.py create mode 100644 lightapi/health.py create mode 100644 lightapi/server.py create mode 100644 tests/test_health.py create mode 100644 tests/test_server_command.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bf2e1bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.venv +.mypy_cache +.pytest_cache +.ruff_cache +__pycache__ +**/__pycache__ +*.pyc +*.db +dist +site +docs +tests +specs +.specify +.cursor +overrides +uv.lock +charts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7d3597 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Generic LightAPI runtime image. +# +# Config is injected at deploy time via LIGHTAPI_CONFIG (a mounted declarative +# YAML file), so the same image serves any API — no per-project build. +FROM python:3.12-slim + +WORKDIR /src +COPY . /src + +# Framework + DB drivers a deployed API may need: psycopg2 (sync Postgres, the +# driver create_engine uses for postgresql://) and the async extra (asyncpg). +RUN pip install --no-cache-dir ".[async]" psycopg2-binary + +ENV LIGHTAPI_CONFIG=/etc/lightapi/lightapi.yaml \ + LIGHTAPI_HOST=0.0.0.0 \ + LIGHTAPI_PORT=8000 + +EXPOSE 8000 +ENTRYPOINT ["lightapi", "serve"] diff --git a/charts/lightapi/Chart.yaml b/charts/lightapi/Chart.yaml new file mode 100644 index 0000000..0737892 --- /dev/null +++ b/charts/lightapi/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: lightapi +description: Deploy a declarative CRUD REST API (LightAPI) over an existing database with only a values.yaml — no CRUD code. +type: application +version: 0.1.0 +appVersion: "0.1.21" +home: https://github.com/iklobato/lightapi +sources: + - https://github.com/iklobato/lightapi +maintainers: + - name: iklobato + email: iklobato1@gmail.com diff --git a/charts/lightapi/templates/NOTES.txt b/charts/lightapi/templates/NOTES.txt new file mode 100644 index 0000000..8ef6981 --- /dev/null +++ b/charts/lightapi/templates/NOTES.txt @@ -0,0 +1,13 @@ +LightAPI released as {{ include "lightapi.fullname" . }}. + +Reach the API locally: + kubectl port-forward svc/{{ include "lightapi.fullname" . }} 8000:{{ .Values.service.port }} + curl http://127.0.0.1:8000/healthz + +Endpoints are defined under .Values.config.endpoints. Add a table = add an entry +there and run `helm upgrade`. No CRUD code required. +{{- if not .Values.database.existingSecret }} + +NOTE: the database URL came from .Values.database.url (a chart-managed Secret). +For production, create your own Secret and set database.existingSecret instead. +{{- end }} diff --git a/charts/lightapi/templates/_helpers.tpl b/charts/lightapi/templates/_helpers.tpl new file mode 100644 index 0000000..dc71fda --- /dev/null +++ b/charts/lightapi/templates/_helpers.tpl @@ -0,0 +1,35 @@ +{{- define "lightapi.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "lightapi.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name (include "lightapi.name" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "lightapi.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "lightapi.labels" -}} +helm.sh/chart: {{ include "lightapi.chart" . }} +{{ include "lightapi.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "lightapi.selectorLabels" -}} +app.kubernetes.io/name: {{ include "lightapi.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "lightapi.databaseSecretName" -}} +{{- default (include "lightapi.fullname" .) .Values.database.existingSecret -}} +{{- end -}} + +{{- define "lightapi.jwtSecretName" -}} +{{- default (include "lightapi.fullname" .) .Values.jwt.existingSecret -}} +{{- end -}} diff --git a/charts/lightapi/templates/configmap.yaml b/charts/lightapi/templates/configmap.yaml new file mode 100644 index 0000000..a05a695 --- /dev/null +++ b/charts/lightapi/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "lightapi.fullname" . }}-config + labels: + {{- include "lightapi.labels" . | nindent 4 }} +data: + lightapi.yaml: | +{{ .Values.config | toYaml | indent 4 }} diff --git a/charts/lightapi/templates/deployment.yaml b/charts/lightapi/templates/deployment.yaml new file mode 100644 index 0000000..a6fae30 --- /dev/null +++ b/charts/lightapi/templates/deployment.yaml @@ -0,0 +1,76 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "lightapi.fullname" . }} + labels: + {{- include "lightapi.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "lightapi.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ .Values.config | toYaml | sha256sum }} + labels: + {{- include "lightapi.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "lightapi.fullname" . }} + containers: + - name: lightapi + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + env: + - name: LIGHTAPI_CONFIG + value: /etc/lightapi/lightapi.yaml + - name: LIGHTAPI_HOST + value: "0.0.0.0" + - name: LIGHTAPI_PORT + value: {{ .Values.service.targetPort | quote }} + - name: {{ .Values.database.urlKey }} + valueFrom: + secretKeyRef: + name: {{ include "lightapi.databaseSecretName" . }} + key: {{ .Values.database.urlKey }} + {{- if or .Values.jwt.secret .Values.jwt.existingSecret }} + - name: {{ .Values.jwt.secretKey }} + valueFrom: + secretKeyRef: + name: {{ include "lightapi.jwtSecretName" . }} + key: {{ .Values.jwt.secretKey }} + {{- end }} + {{- if .Values.redis.url }} + - name: LIGHTAPI_REDIS_URL + value: {{ .Values.redis.url | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: config + mountPath: /etc/lightapi + readOnly: true + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: config + configMap: + name: {{ include "lightapi.fullname" . }}-config diff --git a/charts/lightapi/templates/hpa.yaml b/charts/lightapi/templates/hpa.yaml new file mode 100644 index 0000000..364fe5e --- /dev/null +++ b/charts/lightapi/templates/hpa.yaml @@ -0,0 +1,22 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "lightapi.fullname" . }} + labels: + {{- include "lightapi.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "lightapi.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/charts/lightapi/templates/ingress.yaml b/charts/lightapi/templates/ingress.yaml new file mode 100644 index 0000000..01bddbb --- /dev/null +++ b/charts/lightapi/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "lightapi.fullname" . }} + labels: + {{- include "lightapi.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ include "lightapi.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/lightapi/templates/secret.yaml b/charts/lightapi/templates/secret.yaml new file mode 100644 index 0000000..47becc6 --- /dev/null +++ b/charts/lightapi/templates/secret.yaml @@ -0,0 +1,18 @@ +{{- $genDb := and .Values.database.url (not .Values.database.existingSecret) -}} +{{- $genJwt := and .Values.jwt.secret (not .Values.jwt.existingSecret) -}} +{{- if or $genDb $genJwt }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "lightapi.fullname" . }} + labels: + {{- include "lightapi.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if $genDb }} + {{ .Values.database.urlKey }}: {{ .Values.database.url | quote }} + {{- end }} + {{- if $genJwt }} + {{ .Values.jwt.secretKey }}: {{ .Values.jwt.secret | quote }} + {{- end }} +{{- end }} diff --git a/charts/lightapi/templates/service.yaml b/charts/lightapi/templates/service.yaml new file mode 100644 index 0000000..c08d7d8 --- /dev/null +++ b/charts/lightapi/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "lightapi.fullname" . }} + labels: + {{- include "lightapi.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "lightapi.selectorLabels" . | nindent 4 }} diff --git a/charts/lightapi/templates/serviceaccount.yaml b/charts/lightapi/templates/serviceaccount.yaml new file mode 100644 index 0000000..37c7b4e --- /dev/null +++ b/charts/lightapi/templates/serviceaccount.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "lightapi.fullname" . }} + labels: + {{- include "lightapi.labels" . | nindent 4 }} diff --git a/charts/lightapi/values.yaml b/charts/lightapi/values.yaml new file mode 100644 index 0000000..41c0b63 --- /dev/null +++ b/charts/lightapi/values.yaml @@ -0,0 +1,88 @@ +# Default values for the lightapi chart. +# A team ships a CRUD API by editing only `database` and `config` below. + +replicaCount: 2 + +image: + repository: iklobato/lightapi + # Defaults to the chart appVersion when empty. + tag: "" + pullPolicy: IfNotPresent + +nameOverride: "" +fullnameOverride: "" + +# Connection to a database that ALREADY EXISTS. The chart never manages the DB. +database: + # Dev convenience: the chart creates a Secret from this URL. + url: "" + # Prod: name of a Secret you already created that holds the URL under `urlKey`. + existingSecret: "" + # The config below references ${DATABASE_URL}; keep this key in sync if changed. + urlKey: DATABASE_URL + +# Only needed if an endpoint uses JWTAuthentication. +jwt: + secret: "" + existingSecret: "" + secretKey: LIGHTAPI_JWT_SECRET + +# Only needed if an endpoint sets Meta.cache. +redis: + url: "" + +# The declarative LightAPI config, copied verbatim into a ConfigMap and passed +# to LightApi.from_config(). This is the entire developer-facing surface — the +# framework turns it into the SQLAlchemy tables, schemas, and CRUD routes. +config: + database: + url: "${DATABASE_URL}" + defaults: + pagination: + style: page_number + page_size: 20 + endpoints: + - route: /products + fields: + name: + type: str + max_length: 200 + price: + type: float + in_stock: + type: bool + default: true + meta: + methods: [GET, POST, PUT, DELETE] + authentication: + permission: AllowAny + filtering: + fields: [in_stock] + ordering: [price] + +service: + type: ClusterIP + port: 80 + targetPort: 8000 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + # - host: api.example.com + # paths: + # - path: / + # pathType: Prefix + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + +# Left empty on purpose: real requests/limits must be derived from measured +# load, not guessed. Set them per deployment. +resources: {} + +extraEnv: [] diff --git a/docs/deployment/helm-chart-plan.md b/docs/deployment/helm-chart-plan.md new file mode 100644 index 0000000..c6aa54c --- /dev/null +++ b/docs/deployment/helm-chart-plan.md @@ -0,0 +1,167 @@ +# Plan: package LightAPI as a Helm chart (zero-CRUD-code deploys) + +## Goal + +A team that needs a CRUD API over an existing database writes only a +`values.yaml` (DB connection + the endpoint/table definitions) and runs +`helm install`. No Python, no CRUD code, no per-project image build. + +## Why this is small + +LightAPI already does the hard part. `LightApi.from_config(path)` builds a full +CRUD API from a declarative YAML file, supports `${VAR}` env substitution in the +DB URL, and can `reflect` existing tables. So the chart never generates CRUD: +it only has to (1) hand LightAPI that YAML, (2) inject secrets, (3) run the +process, (4) expose it. The YAML the framework already parses *is* the chart's +config surface. + +## What is actually missing (three gaps) + +Confirmed by reading the repo: no server entrypoint that reads a config path +from env, no health route, no Dockerfile/chart. + +### 1. A first-class serve entrypoint (belongs in the package, not the chart) + +Add a `lightapi serve` console command (SOLID: one object owns "boot from +environment"). It replaces the ad-hoc `run_server.py`. + +- New: `lightapi/server.py` with a `ServerCommand` class. It reads + `LIGHTAPI_CONFIG` (config path), `LIGHTAPI_HOST`, `LIGHTAPI_PORT` and calls + `LightApi.from_config(path).run(host, port)`. Env parsing is the object's one + responsibility; it delegates building to `from_config` and serving to `run`. +- `lightapi/__main__.py`: `ServerCommand.from_env().execute()` so + `python -m lightapi` works too. +- `pyproject.toml`: `[project.scripts] lightapi = "lightapi.server:main"`. +- `main()` is a thin adapter over `ServerCommand` (no logic in the free + function; it constructs the object and calls it). + +### 2. A `/healthz` route in the framework (belongs in `LightApi`) + +The probe must not depend on a business endpoint. Add `/healthz` in +`LightApi.build_app()` / `run()` where the Starlette route list is assembled +(same spot the auth routes are inserted, `lightapi.py:376`). A tiny +`HealthCheck` handler returns `{"status": "ok"}` (200). Optionally it pings the +DB engine; start with process-only (liveness) and add a DB ping variant for +readiness later. + +### 3. Container image + chart (the packaging itself) + +Single generic published image; every deploy reuses it and only swaps +`values.yaml`. + +## Deliverables + +``` +Dockerfile # generic image: installs lightapi, ENTRYPOINT ["lightapi","serve"] +lightapi/server.py # ServerCommand (env -> from_config -> run) +lightapi/__main__.py # python -m lightapi +charts/lightapi/ + Chart.yaml # appVersion pinned to the image tag + values.yaml # documented defaults + templates/ + _helpers.tpl # names/labels + configmap.yaml # renders .Values.config (the lightapi.yaml) verbatim + secret.yaml # DATABASE_URL, LIGHTAPI_JWT_SECRET (+ existingSecret opt-out) + deployment.yaml # mounts config, env from secret, /healthz probes + service.yaml + ingress.yaml # optional (.Values.ingress.enabled) + hpa.yaml # optional + serviceaccount.yaml + NOTES.txt # how to reach the API after install +``` + +## values.yaml shape (the entire developer-facing surface) + +```yaml +image: + repository: iklobato/lightapi + tag: "" # defaults to Chart.appVersion + +replicaCount: 2 + +# Connection to a database that already exists (chart does NOT manage the DB). +database: + # either a full URL... + urlSecret: + existingSecret: "" # name of a Secret holding DATABASE_URL + key: DATABASE_URL + # ...or let the chart create the Secret from this value (dev only): + url: "" # e.g. postgresql://user:pass@host:5432/app + +jwt: + existingSecret: "" + secret: "" # LIGHTAPI_JWT_SECRET (required only if endpoints use JWT) + +redis: + url: "" # LIGHTAPI_REDIS_URL, only if an endpoint sets cache + +# This block is copied verbatim into a ConfigMap and passed to from_config(). +# It is exactly the declarative lightapi.yaml the framework already parses. +config: + database: + url: "${DATABASE_URL}" # resolved from the Secret at runtime + defaults: + pagination: { style: page_number, page_size: 20 } + endpoints: + - route: /products + fields: + name: { type: str, max_length: 200 } + price: { type: float } + in_stock: { type: bool, default: true } + meta: + methods: [GET, POST, PUT, DELETE] + filtering: { fields: [in_stock], ordering: [price] } + # To wrap an existing table instead of declaring fields: + - route: /legacy_users + reflect: true + meta: { methods: [GET] } + +ingress: + enabled: false + className: "" + hosts: [] +autoscaling: + enabled: false +resources: {} +``` + +## How the pieces wire at runtime + +1. `configmap.yaml` writes `.Values.config` to `/etc/lightapi/lightapi.yaml`. +2. `deployment.yaml` sets `LIGHTAPI_CONFIG=/etc/lightapi/lightapi.yaml` and pulls + `DATABASE_URL` / `LIGHTAPI_JWT_SECRET` / `LIGHTAPI_REDIS_URL` from the Secret. +3. `ENTRYPOINT lightapi serve` -> `ServerCommand.from_env()` -> `from_config()` + parses the mounted YAML, `${DATABASE_URL}` is substituted from env, tables are + created/reflected, uvicorn serves on `LIGHTAPI_PORT` (8000). +4. Liveness/readiness probes hit `/healthz`. + +## Developer workflow (the payoff) + +```bash +helm install shop charts/lightapi \ + -f my-values.yaml # my-values.yaml = DB url + the endpoints block +``` +No CRUD code written. Adding a table = add an entry under `config.endpoints` and +`helm upgrade`. + +## Decisions locked + +- Database: external (chart is stateless; no bundled Postgres). +- Health: real `/healthz` added to the framework. +- Image: one generic published image, config injected via values. + +## Out of scope (add when needed) + +- Bundled Postgres/Redis subcharts (dev-only convenience). +- DB migrations beyond `create_all` (LightAPI creates missing tables; it does not + run schema migrations). +- Multi-tenant / per-endpoint image splitting. + +## Build order + +1. `ServerCommand` + `__main__` + `[project.scripts]` (self-check: `python -m + lightapi` boots a demo config). +2. `/healthz` route + one test. +3. Dockerfile; build and run the image locally against a Postgres. +4. Chart templates; `helm template` + `helm lint`; `helm install` on kind/minikube. +``` diff --git a/lightapi/__main__.py b/lightapi/__main__.py new file mode 100644 index 0000000..252f679 --- /dev/null +++ b/lightapi/__main__.py @@ -0,0 +1,6 @@ +"""``python -m lightapi`` → serve from the environment.""" + +from lightapi.server import main + +if __name__ == "__main__": + main() diff --git a/lightapi/health.py b/lightapi/health.py new file mode 100644 index 0000000..24b4770 --- /dev/null +++ b/lightapi/health.py @@ -0,0 +1,21 @@ +"""Health-check endpoint for container orchestration probes.""" + +from __future__ import annotations + +from starlette.endpoints import HTTPEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse + +HEALTH_PATH = "/healthz" + + +class HealthCheckEndpoint(HTTPEndpoint): + """Liveness/readiness probe target. + + Registered at :data:`HEALTH_PATH` on every :class:`~lightapi.lightapi.LightApi` + app so Kubernetes (and any other orchestrator) can check the process without + touching a business endpoint. Returns ``200`` with a small JSON body. + """ + + async def get(self, request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) diff --git a/lightapi/lightapi.py b/lightapi/lightapi.py index dd26c72..66e3bfc 100644 --- a/lightapi/lightapi.py +++ b/lightapi/lightapi.py @@ -21,11 +21,9 @@ from lightapi.authentication import AllowAny, BasicAuthentication, JWTAuthentication from lightapi.cache import get_cached, invalidate_cache_prefix, set_cached -from lightapi.constants import ( - RESPONSE_KEY_DETAIL, - HTTPStatus, -) +from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus from lightapi.exceptions import ConfigurationError +from lightapi.health import HEALTH_PATH, HealthCheckEndpoint from lightapi.rest import RestEndpoint from lightapi.session_manager import SessionManager from lightapi.yaml_loader import load_config @@ -577,7 +575,7 @@ def run( self._check_cache_connections() on_startup = [self._create_tables] if self._mode == "async" else [] - app = Starlette(debug=debug, routes=self._routes, on_startup=on_startup) + app = Starlette(debug=debug, routes=self._asgi_routes(), on_startup=on_startup) if self._cors_origins: app.add_middleware( @@ -606,7 +604,14 @@ def build_app(self) -> Starlette: self._create_tables() self._check_cache_connections() on_startup = [self._create_tables] if self._mode == "async" else [] - return Starlette(routes=self._routes, on_startup=on_startup) + return Starlette(routes=self._asgi_routes(), on_startup=on_startup) + + def _asgi_routes(self) -> list[Route]: + """Registered endpoint routes plus the always-on ``/healthz`` probe.""" + return [ + *self._routes, + Route(HEALTH_PATH, HealthCheckEndpoint), + ] # ───────────────────────────────────────────────────────────────────────── # YAML factory diff --git a/lightapi/server.py b/lightapi/server.py new file mode 100644 index 0000000..830e471 --- /dev/null +++ b/lightapi/server.py @@ -0,0 +1,68 @@ +"""Console entry point: boot a LightApi server from the environment. + +Backs the ``lightapi serve`` command and the container image. The Helm chart +sets ``LIGHTAPI_CONFIG`` to the mounted declarative YAML; this command turns the +process environment into a running server, delegating all real work to +:class:`~lightapi.lightapi.LightApi`. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping + +from lightapi.exceptions import ConfigurationError +from lightapi.lightapi import LightApi + + +class ServerCommand: + """Boots and serves a LightApi instance from environment configuration. + + Single responsibility: translate the process environment into a running + server. Building the app is delegated to :meth:`LightApi.from_config`, + serving to :meth:`LightApi.run`. + """ + + DEFAULT_HOST = "0.0.0.0" + DEFAULT_PORT = 8000 + + def __init__(self, config_path: str, host: str, port: int) -> None: + self._config_path = config_path + self._host = host + self._port = port + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> ServerCommand: + config_path = env.get("LIGHTAPI_CONFIG") + if not config_path: + raise ConfigurationError( + "LIGHTAPI_CONFIG is not set. Point it at the declarative YAML " + "config file (e.g. /etc/lightapi/lightapi.yaml)." + ) + host = env.get("LIGHTAPI_HOST") or cls.DEFAULT_HOST + port = cls._parse_port(env.get("LIGHTAPI_PORT")) + return cls(config_path, host, port) + + @classmethod + def _parse_port(cls, raw: str | None) -> int: + if not raw: + return cls.DEFAULT_PORT + try: + return int(raw) + except ValueError as exc: + raise ConfigurationError( + f"LIGHTAPI_PORT must be an integer, got '{raw}'." + ) from exc + + def execute(self) -> None: + LightApi.from_config(self._config_path).run(host=self._host, port=self._port) + + +def main(argv: list[str] | None = None) -> None: + """Console-script entry point for the ``lightapi`` command.""" + args = sys.argv[1:] if argv is None else argv + command = args[0] if args else "serve" + if command != "serve": + raise SystemExit(f"Unknown command '{command}'. Usage: lightapi serve") + ServerCommand.from_env(os.environ).execute() diff --git a/pyproject.toml b/pyproject.toml index cde68dc..91dce1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,9 @@ docs = [ "mkdocs-git-authors-plugin", ] +[project.scripts] +lightapi = "lightapi.server:main" + [project.urls] Repository = "https://github.com/henriqueblobato/LightApi" Issues = "https://github.com/henriqueblobato/LightApi/issues" diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..3e4a9d1 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,28 @@ +"""The always-on /healthz probe route.""" + +from pydantic import Field +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint + + +def _client() -> TestClient: + class Item(RestEndpoint): + name: str = Field(min_length=1) + + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + app = LightApi(engine=engine) + app.register({"/items": Item}) + return TestClient(app.build_app()) + + +def test_healthz_returns_ok() -> None: + resp = _client().get("/healthz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} diff --git a/tests/test_server_command.py b/tests/test_server_command.py new file mode 100644 index 0000000..dcbd06f --- /dev/null +++ b/tests/test_server_command.py @@ -0,0 +1,34 @@ +"""ServerCommand: environment → server configuration.""" + +import pytest + +from lightapi.exceptions import ConfigurationError +from lightapi.server import ServerCommand + + +def test_from_env_requires_config() -> None: + with pytest.raises(ConfigurationError): + ServerCommand.from_env({}) + + +def test_from_env_applies_defaults() -> None: + cmd = ServerCommand.from_env({"LIGHTAPI_CONFIG": "/etc/lightapi/lightapi.yaml"}) + assert cmd._host == "0.0.0.0" + assert cmd._port == 8000 + + +def test_from_env_reads_host_and_port() -> None: + cmd = ServerCommand.from_env( + { + "LIGHTAPI_CONFIG": "/c.yaml", + "LIGHTAPI_HOST": "127.0.0.1", + "LIGHTAPI_PORT": "9000", + } + ) + assert cmd._host == "127.0.0.1" + assert cmd._port == 9000 + + +def test_from_env_rejects_non_integer_port() -> None: + with pytest.raises(ConfigurationError): + ServerCommand.from_env({"LIGHTAPI_CONFIG": "/c.yaml", "LIGHTAPI_PORT": "abc"}) From 68c36615301d12d726302d1af6d12688bbcdfec6 Mon Sep 17 00:00:00 2001 From: iklo Date: Sat, 1 Aug 2026 19:27:43 -0300 Subject: [PATCH 15/15] docs: document the Helm chart, serve command, and /healthz - README: env vars (LIGHTAPI_CONFIG/HOST/PORT), a `lightapi serve` note, a health-check note, and a Kubernetes (Helm) section. - docs/deployment/helm.md: full Helm guide with a minikube walkthrough. - charts/lightapi/README.md: chart usage and a complete values reference. - docs/deployment/docker.md: align with the real image (lightapi serve + LIGHTAPI_CONFIG) instead of the old gunicorn placeholder. - Add the Helm page to the deployment nav. Claude-Session: https://claude.ai/code/session_01LWV5jPTwsAehx2rM7UZjtq --- README.md | 55 +++++++++++++++ charts/lightapi/README.md | 81 +++++++++++++++++++++++ docs/deployment/.pages | 1 + docs/deployment/docker.md | 83 +++++++++++++++-------- docs/deployment/helm.md | 136 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 27 deletions(-) create mode 100644 charts/lightapi/README.md create mode 100644 docs/deployment/helm.md diff --git a/README.md b/README.md index 07b22f4..e6d4ed7 100644 --- a/README.md +++ b/README.md @@ -969,6 +969,9 @@ client = TestClient(app_instance.build_app()) | `LIGHTAPI_DATABASE_URL` | — | Database connection URL when no `engine` or `database_url` is passed. One of `engine`, `database_url`, or `LIGHTAPI_DATABASE_URL` is required. | | `LIGHTAPI_JWT_SECRET` | — | Required for `JWTAuthentication` | | `LIGHTAPI_REDIS_URL` | `redis://localhost:6379/0` | Redis URL for response caching | +| `LIGHTAPI_CONFIG` | — | Path to the declarative YAML config. Read by the `lightapi serve` command and the container image. | +| `LIGHTAPI_HOST` | `0.0.0.0` | Bind host for `lightapi serve`. | +| `LIGHTAPI_PORT` | `8000` | Bind port for `lightapi serve`. | ### Docker @@ -1000,6 +1003,58 @@ services: image: redis:7-alpine ``` +### `lightapi serve` command + +Installing LightAPI adds a `lightapi` console command. `lightapi serve` boots a +server from the environment: it reads `LIGHTAPI_CONFIG` (the declarative YAML), +`LIGHTAPI_HOST`, and `LIGHTAPI_PORT`, so a container needs no custom entry +script. `python -m lightapi` does the same. + +```bash +export LIGHTAPI_CONFIG=./lightapi.yaml +export DATABASE_URL=postgresql://postgres:pass@localhost:5432/app +lightapi serve +``` + +### Health check + +Every app registers `GET /healthz`, which returns `200 {"status": "ok"}`. It is +process-level (it does not check the database) and is meant for liveness and +readiness probes. + +### Kubernetes (Helm) + +The `charts/lightapi` chart deploys a declarative CRUD API over an existing +database with only a `values.yaml` (no CRUD code and no per-project image +build). The chart passes your declarative config to `LightApi.from_config` and +runs it; adding a table means adding an entry under `config.endpoints` and +running `helm upgrade`. + +```bash +helm install shop charts/lightapi -f my-values.yaml +``` + +```yaml +# my-values.yaml: the entire developer-facing surface +database: + url: "postgresql://postgres:pass@my-postgres:5432/app" # or database.existingSecret +config: + database: + url: "${DATABASE_URL}" # resolved from the Secret at runtime + endpoints: + - route: /products + fields: + name: { type: str, max_length: 200 } + price: { type: float } + meta: + methods: [GET, POST, PUT, DELETE] + authentication: { permission: AllowAny } +``` + +See [docs/deployment/helm.md](docs/deployment/helm.md) and +[charts/lightapi/README.md](charts/lightapi/README.md) for the full values +reference and a minikube walkthrough. + --- ## Contributing diff --git a/charts/lightapi/README.md b/charts/lightapi/README.md new file mode 100644 index 0000000..1605602 --- /dev/null +++ b/charts/lightapi/README.md @@ -0,0 +1,81 @@ +# lightapi Helm chart + +Deploy a declarative CRUD REST API (LightAPI) over an existing database with only +a `values.yaml`. No CRUD code, no per-project image build. + +The chart renders your `config` block into a ConfigMap, passes it to +`LightApi.from_config`, injects the database URL from a Secret, and serves the +app. Adding a table means adding an entry under `config.endpoints` and running +`helm upgrade`. + +## Install + +```bash +helm install shop charts/lightapi -f my-values.yaml +``` + +## Values + +| Key | Default | Description | +|-----|---------|-------------| +| `replicaCount` | `2` | Replicas (ignored when `autoscaling.enabled`). | +| `image.repository` | `iklobato/lightapi` | Image repository. | +| `image.tag` | `""` | Image tag; defaults to the chart `appVersion`. | +| `image.pullPolicy` | `IfNotPresent` | Image pull policy. | +| `nameOverride` / `fullnameOverride` | `""` | Override generated names. | +| `database.url` | `""` | Connection URL. When set (and no `existingSecret`), the chart creates a Secret from it. Dev convenience. | +| `database.existingSecret` | `""` | Name of a Secret you manage that holds the URL. Production path; the chart creates no Secret. | +| `database.urlKey` | `DATABASE_URL` | Key under which the URL lives in the Secret. Keep in sync with the `${DATABASE_URL}` placeholder in `config`. | +| `jwt.secret` | `""` | JWT secret; chart-managed Secret (only if an endpoint uses `JWTAuthentication`). | +| `jwt.existingSecret` | `""` | Name of a Secret holding the JWT secret. | +| `jwt.secretKey` | `LIGHTAPI_JWT_SECRET` | Key for the JWT secret. | +| `redis.url` | `""` | `LIGHTAPI_REDIS_URL`; only needed when an endpoint sets `Meta.cache`. | +| `config` | products example | The declarative LightAPI config, copied verbatim into a ConfigMap. This is the whole developer-facing surface. | +| `service.type` | `ClusterIP` | Service type. | +| `service.port` | `80` | Service port. | +| `service.targetPort` | `8000` | Container port (`LIGHTAPI_PORT`). | +| `ingress.enabled` | `false` | Create an Ingress. | +| `ingress.className` | `""` | Ingress class. | +| `ingress.annotations` | `{}` | Ingress annotations. | +| `ingress.hosts` | `[]` | List of `{host, paths: [{path, pathType}]}`. | +| `autoscaling.enabled` | `false` | Create an HPA. When on, `replicas` is not set (the HPA owns it). | +| `autoscaling.minReplicas` | `2` | HPA min replicas. | +| `autoscaling.maxReplicas` | `5` | HPA max replicas. | +| `autoscaling.targetCPUUtilizationPercentage` | `80` | HPA CPU target. | +| `resources` | `{}` | Container resources. Left empty on purpose: derive from measured load, do not guess. | +| `extraEnv` | `[]` | Extra environment variables (list of `{name, value}`). | + +## The `config` block + +`config` is the declarative `lightapi.yaml` LightAPI already understands. Example: + +```yaml +config: + database: + url: "${DATABASE_URL}" # resolved from the Secret at runtime + defaults: + pagination: { style: page_number, page_size: 20 } + endpoints: + - route: /products + fields: + name: { type: str, max_length: 200 } + price: { type: float } + in_stock: { type: bool, default: true } + meta: + methods: [GET, POST, PUT, DELETE] + authentication: { permission: AllowAny } + filtering: { fields: [in_stock], ordering: [price] } + # Wrap an existing table instead of declaring fields: + - route: /legacy_users + reflect: true + meta: { methods: [GET] } +``` + +## Health + +Liveness and readiness probes target `GET /healthz`, which every LightAPI app +registers. A pod with an invalid config stays `NotReady` and serves no traffic. + +## Full guide + +See the [Kubernetes (Helm) deployment guide](https://github.com/iklobato/lightapi/blob/master/docs/deployment/helm.md). diff --git a/docs/deployment/.pages b/docs/deployment/.pages index 30b2a4f..425c9ea 100644 --- a/docs/deployment/.pages +++ b/docs/deployment/.pages @@ -3,4 +3,5 @@ title: Deployment nav: - Production Deployment: production.md - Docker Deployment: docker.md + - Kubernetes (Helm): helm.md - Security Considerations: security.md diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 732da22..e965464 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -2,49 +2,78 @@ title: Docker Deployment --- -## Dockerfile +LightAPI ships a generic image whose entry point is `lightapi serve`. That +command reads `LIGHTAPI_CONFIG` (the declarative YAML), `LIGHTAPI_HOST`, and +`LIGHTAPI_PORT` from the environment, so the same image serves any API: you +inject the config at run time instead of baking it in. -```dockerfile -# Use official Python base image -FROM python:3.10-slim +## Dockerfile -# Set working directory -WORKDIR /app +The repository root has a ready-to-use `Dockerfile`: -# Copy requirements and install -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +```dockerfile +FROM python:3.12-slim +WORKDIR /src +COPY . /src +# Framework + DB drivers a deployed API may need (sync + async Postgres). +RUN pip install --no-cache-dir ".[async]" psycopg2-binary +ENV LIGHTAPI_CONFIG=/etc/lightapi/lightapi.yaml \ + LIGHTAPI_HOST=0.0.0.0 \ + LIGHTAPI_PORT=8000 +EXPOSE 8000 +ENTRYPOINT ["lightapi", "serve"] +``` -# Copy application code -COPY . . +Build it: -# Expose port and run -EXPOSE 8000 -CMD ["gunicorn", "app.main:app", "--worker-class", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"] +```bash +docker build -t youracct/lightapi:0.1.21 . ``` ## docker-compose.yml +Mount your declarative config and point `DATABASE_URL` at the database. The +config references `${DATABASE_URL}`, which LightAPI substitutes at startup. + ```yaml -version: '3.8' services: api: build: . - ports: - - "8000:8000" + ports: ["8000:8000"] + environment: + DATABASE_URL: postgresql://postgres:pass@db:5432/mydb + # LIGHTAPI_JWT_SECRET / LIGHTAPI_REDIS_URL only if used by an endpoint + volumes: + - ./lightapi.yaml:/etc/lightapi/lightapi.yaml:ro + depends_on: [db] + db: + image: postgres:16-alpine environment: - - LIGHTAPI_DATABASE_URL=sqlite:///./app.db - - LIGHTAPI_JWT_SECRET=supersecret - depends_on: - - redis - redis: - image: redis:7-alpine - ports: - - "6379:6379" + POSTGRES_DB: mydb + POSTGRES_USER: postgres + POSTGRES_PASSWORD: pass ``` -### Building and Running +`lightapi.yaml` next to the compose file: + +```yaml +database: + url: "${DATABASE_URL}" +endpoints: + - route: /products + fields: + name: { type: str, max_length: 200 } + price: { type: float } + meta: + methods: [GET, POST, PUT, DELETE] + authentication: { permission: AllowAny } +``` + +### Building and running ```bash -docker-compose up --build +docker compose up --build +curl http://127.0.0.1:8000/healthz ``` + +For Kubernetes, see the [Kubernetes (Helm)](helm.md) guide. diff --git a/docs/deployment/helm.md b/docs/deployment/helm.md new file mode 100644 index 0000000..a7a15e3 --- /dev/null +++ b/docs/deployment/helm.md @@ -0,0 +1,136 @@ +--- +title: Kubernetes (Helm) +--- + +Deploy a full CRUD REST API over a database that already exists by writing only +a `values.yaml`. No CRUD code, no per-project image build. + +LightAPI already turns a declarative YAML config into SQLAlchemy tables, Pydantic +schemas, and CRUD routes (see [YAML Configuration](../examples/yaml-configuration.md)). +The chart's only job is to hand that config to `LightApi.from_config`, inject the +database secret, run the process, and expose it. The config block in `values.yaml` +is exactly the declarative `lightapi.yaml` the framework parses. + +## Prerequisites + +- A running Kubernetes cluster (minikube, kind, or a real one). +- `helm` 3.x and `kubectl`. +- A reachable database. The chart connects to an existing database; it does not + manage one. + +## The container image + +The chart runs a generic image whose entry point is `lightapi serve`. That +command reads `LIGHTAPI_CONFIG`, `LIGHTAPI_HOST`, and `LIGHTAPI_PORT` from the +environment. Build and publish it once: + +```bash +docker build -t youracct/lightapi:0.1.21 . +docker push youracct/lightapi:0.1.21 +``` + +Point the chart at it with `image.repository` and `image.tag`. + +## Install + +```bash +helm install shop charts/lightapi -f my-values.yaml +``` + +A minimal `my-values.yaml`: + +```yaml +image: + repository: youracct/lightapi + tag: "0.1.21" + +database: + # Dev: the chart creates a Secret from this URL. + url: "postgresql://postgres:pass@my-postgres:5432/app" + +config: + database: + url: "${DATABASE_URL}" # resolved from the Secret at runtime + endpoints: + - route: /products + fields: + name: { type: str, max_length: 200 } + price: { type: float } + meta: + methods: [GET, POST, PUT, DELETE] + authentication: { permission: AllowAny } +``` + +## Add a table later + +Add an entry under `config.endpoints` and upgrade. The ConfigMap checksum changes, +so the pods roll automatically: + +```bash +helm upgrade shop charts/lightapi -f my-values.yaml +``` + +## Database credentials + +Two ways to provide the connection URL: + +- **Dev**: set `database.url`. The chart creates a Secret from it. +- **Production**: create your own Secret and set `database.existingSecret` to its + name. The chart then references it and creates no Secret of its own. Keep the + key name in `database.urlKey` (default `DATABASE_URL`) in sync with the + `${DATABASE_URL}` placeholder in `config.database.url`. + +```bash +kubectl create secret generic my-db \ + --from-literal=DATABASE_URL='postgresql://user:pass@host:5432/app' + +helm upgrade shop charts/lightapi -f my-values.yaml \ + --set database.existingSecret=my-db +``` + +JWT and Redis follow the same pattern via `jwt.*` and `redis.url` (only needed +when an endpoint uses `JWTAuthentication` or `Meta.cache`). + +## Health, probes, and autoscaling + +- Liveness and readiness probes hit `GET /healthz` (always registered). A pod + that fails to boot (for example, an invalid config) stays `NotReady` and + receives no traffic. +- Set `ingress.enabled: true` with `ingress.hosts` to publish through an Ingress. +- Set `autoscaling.enabled: true` to create an HPA. When it is on, the chart does + not set `replicas` (the HPA owns it). + +## minikube walkthrough + +```bash +minikube start +docker build -t lightapi:dev . +minikube image load lightapi:dev + +# a throwaway Postgres in the cluster to connect to +kubectl create deployment pg --image=postgres:16-alpine +kubectl set env deployment/pg POSTGRES_PASSWORD=pass POSTGRES_DB=app +kubectl expose deployment pg --port=5432 + +helm install shop charts/lightapi \ + --set image.repository=lightapi --set image.tag=dev --set image.pullPolicy=Never \ + --set database.url='postgresql://postgres:pass@pg:5432/app' + +kubectl rollout status deploy/shop-lightapi +kubectl port-forward svc/shop-lightapi 8000:80 + +curl http://127.0.0.1:8000/healthz +curl -X POST http://127.0.0.1:8000/products \ + -H 'Content-Type: application/json' -d '{"name":"Widget","price":9.5}' +``` + +## Uninstall + +```bash +helm uninstall shop +``` + +## Values reference + +See [charts/lightapi/README.md](https://github.com/iklobato/lightapi/blob/master/charts/lightapi/README.md) +for the complete list of values and defaults.