{"id":34082336,"url":"https://github.com/iklobato/lightapi","last_synced_at":"2026-03-07T06:15:08.420Z","repository":{"id":241603280,"uuid":"807247900","full_name":"iklobato/lightapi","owner":"iklobato","description":"LightApi is a lightweight API framework designed for rapid development of RESTful APIs in Python. It provides a simple and intuitive interface for defining endpoints and handling HTTP requests without the need for complex configuration or dependencies.","archived":false,"fork":false,"pushed_at":"2026-03-05T07:13:51.000Z","size":1228,"stargazers_count":109,"open_issues_count":5,"forks_count":7,"subscribers_count":3,"default_branch":"master","last_synced_at":"2026-03-05T11:37:17.126Z","etag":null,"topics":["api","api-rest","lightweight","mvp","poc","prototype","python","rest","rest-api","server","startups","webapp"],"latest_commit_sha":null,"homepage":"https://iklobato.github.io/lightapi/","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/iklobato.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-05-28T18:27:14.000Z","updated_at":"2026-03-05T07:13:55.000Z","dependencies_parsed_at":"2024-07-31T04:43:50.046Z","dependency_job_id":"ecb2faf8-32bd-4816-b163-5cd433d10272","html_url":"https://github.com/iklobato/lightapi","commit_stats":null,"previous_names":["henriqueblobato/lightapi","iklobato/lightapi"],"tags_count":16,"template":false,"template_full_name":null,"purl":"pkg:github/iklobato/lightapi","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iklobato%2Flightapi","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iklobato%2Flightapi/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iklobato%2Flightapi/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iklobato%2Flightapi/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/iklobato","download_url":"https://codeload.github.com/iklobato/lightapi/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iklobato%2Flightapi/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30208891,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-07T05:23:27.321Z","status":"ssl_error","status_checked_at":"2026-03-07T05:00:17.256Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","api-rest","lightweight","mvp","poc","prototype","python","rest","rest-api","server","startups","webapp"],"created_at":"2025-12-14T12:12:54.185Z","updated_at":"2026-03-07T06:15:08.410Z","avatar_url":"https://github.com/iklobato.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# LightAPI v2: Annotation-Driven Python REST Framework\n\n[![PyPI version](https://badge.fury.io/py/lightapi.svg)](https://pypi.org/project/lightapi/)\n[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\n**LightAPI** is a Python REST API framework where a single annotated class is simultaneously your ORM model, your Pydantic v2 schema, and your REST endpoint. Declare fields once — LightAPI auto-generates the SQLAlchemy table, validates input, handles CRUD, enforces optimistic locking, filters, paginates, and caches.\n\n---\n\n## Table of Contents\n\n- [Why LightAPI v2?](#why-lightapi-v2)\n- [Installation](#installation)\n- [Quick Start](#quick-start)\n- [Core Concepts](#core-concepts)\n  - [RestEndpoint and Field](#restendpoint-and-field)\n  - [Auto-injected Columns](#auto-injected-columns)\n  - [Optimistic Locking](#optimistic-locking)\n  - [HttpMethod Mixins](#httpmethod-mixins)\n  - [Serializer](#serializer)\n  - [Authentication and Permissions](#authentication-and-permissions)\n  - [Filtering, Search, and Ordering](#filtering-search-and-ordering)\n  - [Pagination](#pagination)\n  - [Custom Queryset](#custom-queryset)\n  - [Response Caching](#response-caching)\n  - [Middleware](#middleware)\n  - [Database Reflection](#database-reflection)\n  - [YAML Configuration](#yaml-configuration)\n- [Async Support](#async-support)\n  - [Enabling Async I/O](#enabling-async-io)\n  - [Async Queryset](#async-queryset)\n  - [Async Method Overrides](#async-method-overrides)\n  - [Background Tasks](#background-tasks)\n  - [Async Middleware](#async-middleware)\n  - [Sync Endpoints on an Async App](#sync-endpoints-on-an-async-app)\n- [API Reference](#api-reference)\n- [Testing](#testing)\n- [Contributing](#contributing)\n- [License](#license)\n\n---\n\n## Why LightAPI v2?\n\n- **One class, three roles**: Your `RestEndpoint` subclass is the SQLAlchemy ORM model, the Pydantic v2 schema, *and* the HTTP handler — no separate files, no boilerplate.\n- **Annotation-driven columns**: Write `title: str = Field(min_length=1)` — LightAPI creates the `VARCHAR` column, the Pydantic constraint, and the API validation all at once.\n- **Optimistic locking built in**: Every endpoint gets a `version` field. `PUT`/`PATCH` require `version` in the body; mismatches return `409 Conflict`.\n- **Opt-in async I/O**: Swap `create_engine` for `create_async_engine` — LightAPI automatically uses `AsyncSession` for every request. Sync and async endpoints coexist on the same app instance.\n- **No aiohttp**: Pure Starlette + Uvicorn ASGI stack, no async framework mixing.\n- **Pydantic v2**: Full `model_validate`, `model_dump(mode='json')`, `ConfigDict` compatibility.\n- **SQLAlchemy 2.0 imperative mapping**: No `DeclarativeBase` inheritance required.\n\n---\n\n## Installation\n\n```bash\n# Using uv (recommended)\nuv add lightapi\n\n# Or pip\npip install lightapi\n```\n\n**Requirements**: Python 3.10+, SQLAlchemy 2.x, Pydantic v2, Starlette, Uvicorn.\n\n**Optional async I/O** (PostgreSQL / SQLite async):\n\n```bash\n# asyncpg (PostgreSQL async driver)\nuv add \"lightapi[async]\"\n# installs: sqlalchemy[asyncio], asyncpg, aiosqlite, greenlet\n```\n\n**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.\n\n---\n\n## Quick Start\n\n```python\nfrom sqlalchemy import create_engine\nfrom lightapi import LightApi, RestEndpoint, Field\n\nclass BookEndpoint(RestEndpoint):\n    title: str = Field(min_length=1)\n    author: str = Field(min_length=1)\n\nengine = create_engine(\"sqlite:///books.db\")\napp = LightApi(engine=engine)\napp.register({\"/books\": BookEndpoint})\n\nif __name__ == \"__main__\":\n    app.run()\n```\n\nThat's it. You now have:\n\n| Method | URL | Description |\n|--------|-----|-------------|\n| `GET` | `/books` | List all books (`{\"results\": [...]}`) |\n| `POST` | `/books` | Create a book (validates `title` min_length=1) |\n| `GET` | `/books/{id}` | Retrieve one book |\n| `PUT` | `/books/{id}` | Full update (requires `version`) |\n| `PATCH` | `/books/{id}` | Partial update (requires `version`) |\n| `DELETE` | `/books/{id}` | Delete (returns 204) |\n\n```bash\n# Create\ncurl -X POST http://localhost:8000/books \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"title\": \"Clean Code\", \"author\": \"Robert Martin\"}'\n# → 201 {\"id\": 1, \"title\": \"Clean Code\", \"author\": \"Robert Martin\", \"version\": 1, ...}\n\n# Update (must supply version)\ncurl -X PUT http://localhost:8000/books/1 \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"title\": \"Clean Code (2nd Ed)\", \"author\": \"Robert Martin\", \"version\": 1}'\n# → 200 {\"id\": 1, \"version\": 2, ...}\n\n# Stale version\ncurl -X PUT http://localhost:8000/books/1 \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"title\": \"Clash\", \"author\": \"X\", \"version\": 1}'\n# → 409 {\"detail\": \"version conflict\"}\n```\n\n---\n\n## Core Concepts\n\n### RestEndpoint and Field\n\nDeclare fields using Python type annotations and `Field()`:\n\n```python\nfrom lightapi import RestEndpoint, Field\nfrom typing import Optional\nfrom decimal import Decimal\n\nclass ProductEndpoint(RestEndpoint):\n    name: str = Field(min_length=1, max_length=200)\n    price: Decimal = Field(ge=0, decimal_places=2)\n    category: str = Field(min_length=1)\n    description: Optional[str] = None  # nullable column, no constraint\n    in_stock: bool = Field(default=True)\n```\n\n**Supported types and their SQLAlchemy column mappings:**\n\n| Python annotation | Column type | Nullable |\n|---|---|---|\n| `str` | `VARCHAR` | No |\n| `Optional[str]` | `VARCHAR` | Yes |\n| `int` | `INTEGER` | No |\n| `Optional[int]` | `INTEGER` | Yes |\n| `float` | `FLOAT` | No |\n| `bool` | `BOOLEAN` | No |\n| `datetime` | `DATETIME` | No |\n| `Decimal` | `NUMERIC(scale=N)` | No |\n| `UUID` | `UUID` | No |\n\n**LightAPI-specific `Field()` kwargs** (stored in `json_schema_extra`, not passed to Pydantic):\n\n| Kwarg | Effect |\n|---|---|\n| `foreign_key=\"table.col\"` | Adds `ForeignKey` constraint on the column |\n| `unique=True` | Adds `UNIQUE` constraint |\n| `index=True` | Adds a database index |\n| `exclude=True` | Column is skipped entirely (no DB column, no schema field) |\n| `decimal_places=N` | Sets `Numeric(scale=N)` (used with `Decimal` type) |\n\n### Auto-injected Columns\n\nEvery `RestEndpoint` subclass automatically gets these columns — you never declare them:\n\n| Column | Type | Default |\n|---|---|---|\n| `id` | `Integer` PK | autoincrement |\n| `created_at` | `DateTime` | `utcnow` on insert |\n| `updated_at` | `DateTime` | `utcnow` on insert + update |\n| `version` | `Integer` | `1` on insert, incremented on each `PUT`/`PATCH` |\n\n`id`, `created_at`, `updated_at`, and `version` are excluded from the create/update input schema but included in all responses.\n\n### Optimistic Locking\n\nEvery `PUT` and `PATCH` request **must** include `version` in the JSON body:\n\n```bash\n# First fetch the current version\ncurl http://localhost:8000/products/42\n# → {\"id\": 42, \"name\": \"Widget\", \"version\": 3, ...}\n\n# Update with correct version\ncurl -X PATCH http://localhost:8000/products/42 \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\": \"Super Widget\", \"version\": 3}'\n# → 200 {\"id\": 42, \"name\": \"Super Widget\", \"version\": 4, ...}\n\n# Concurrent update with stale version → conflict\ncurl -X PATCH http://localhost:8000/products/42 \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\": \"Other Widget\", \"version\": 3}'\n# → 409 {\"detail\": \"version conflict\"}\n```\n\nMissing `version` returns `422 Unprocessable Entity`.\n\n### HttpMethod Mixins\n\nControl which HTTP verbs your endpoint exposes by mixing in `HttpMethod.*` classes:\n\n```python\nfrom lightapi import RestEndpoint, HttpMethod, Field\n\nclass ReadOnlyEndpoint(RestEndpoint, HttpMethod.GET):\n    \"\"\"Only GET /items and GET /items/{id} are registered.\"\"\"\n    name: str = Field(min_length=1)\n\nclass CreateOnlyEndpoint(RestEndpoint, HttpMethod.POST):\n    \"\"\"Only POST /items is registered.\"\"\"\n    name: str = Field(min_length=1)\n\nclass StandardEndpoint(RestEndpoint, HttpMethod.GET, HttpMethod.POST,\n                        HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE):\n    \"\"\"Explicit full CRUD — same as the default with no mixins.\"\"\"\n    name: str = Field(min_length=1)\n```\n\nUnregistered methods return `405 Method Not Allowed` with an `Allow` header.\n\n### Serializer\n\nControl which fields appear in responses, globally or per-verb:\n\n```python\nfrom lightapi import RestEndpoint, Serializer, Field\n\n# Form 1 — all verbs, all fields (default)\nclass Ep1(RestEndpoint):\n    name: str = Field(min_length=1)\n\n# Form 2 — restrict to a subset for all verbs\nclass Ep2(RestEndpoint):\n    name: str = Field(min_length=1)\n    internal_code: str = Field(min_length=1)\n    class Meta:\n        serializer = Serializer(fields=[\"id\", \"name\"])\n\n# Form 3 — different fields for reads vs writes\nclass Ep3(RestEndpoint):\n    name: str = Field(min_length=1)\n    class Meta:\n        serializer = Serializer(\n            read=[\"id\", \"name\", \"created_at\", \"version\"],\n            write=[\"id\", \"name\"],\n        )\n\n# Form 4 — reusable subclass, shared across endpoints\nclass PublicSerializer(Serializer):\n    read = [\"id\", \"name\", \"created_at\"]\n    write = [\"id\", \"name\"]\n\nclass Ep4(RestEndpoint):\n    name: str = Field(min_length=1)\n    class Meta:\n        serializer = PublicSerializer\n\nclass Ep5(RestEndpoint):\n    name: str = Field(min_length=1)\n    class Meta:\n        serializer = PublicSerializer  # reused\n```\n\n### Authentication and Permissions\n\nUse `Meta.authentication` with a backend and an optional permission class:\n\n```python\nimport os\nfrom lightapi import RestEndpoint, Authentication, Field\nfrom lightapi import JWTAuthentication, IsAuthenticated, IsAdminUser\n\nos.environ[\"LIGHTAPI_JWT_SECRET\"] = \"your-secret-key\"\n\nclass ProtectedEndpoint(RestEndpoint):\n    secret: str = Field(min_length=1)\n    class Meta:\n        authentication = Authentication(backend=JWTAuthentication)\n\nclass AdminOnlyEndpoint(RestEndpoint):\n    data: str = Field(min_length=1)\n    class Meta:\n        authentication = Authentication(\n            backend=JWTAuthentication,\n            permission=IsAdminUser,   # requires payload[\"is_admin\"] == True\n        )\n```\n\n**Request flow:**\n1. `JWTAuthentication.authenticate(request)` — extracts and validates `Authorization: Bearer \u003ctoken\u003e`, stores payload in `request.state.user`\n2. Permission class `.has_permission(request)` — checks `request.state.user`\n3. Returns `401` if authentication fails, `403` if permission denied\n\n**Built-in permission classes:**\n\n| Class | Condition |\n|---|---|\n| `AllowAny` | Always allowed (default) |\n| `IsAuthenticated` | `request.state.user` is not None |\n| `IsAdminUser` | `request.state.user[\"is_admin\"] == True` |\n\n### Filtering, Search, and Ordering\n\nDeclare filter backends and allowed fields in `Meta.filtering`:\n\n```python\nfrom lightapi import RestEndpoint, Filtering, Field\nfrom lightapi.filters import FieldFilter, SearchFilter, OrderingFilter\n\nclass ArticleEndpoint(RestEndpoint):\n    title: str = Field(min_length=1)\n    category: str = Field(min_length=1)\n    author: str = Field(min_length=1)\n\n    class Meta:\n        filtering = Filtering(\n            backends=[FieldFilter, SearchFilter, OrderingFilter],\n            fields=[\"category\"],           # ?category=news  (exact match)\n            search=[\"title\", \"author\"],    # ?search=python  (case-insensitive LIKE)\n            ordering=[\"title\", \"author\"],  # ?ordering=title or ?ordering=-title\n        )\n```\n\n**Query parameters:**\n\n```bash\n# Exact filter (whitelisted fields only)\nGET /articles?category=news\n\n# Full-text search across title and author\nGET /articles?search=python\n\n# Ordering (prefix - for descending)\nGET /articles?ordering=-title\n\n# Combine all\nGET /articles?category=news\u0026search=python\u0026ordering=-title\n```\n\n### Pagination\n\n```python\nfrom lightapi import RestEndpoint, Pagination, Field\n\nclass PostEndpoint(RestEndpoint):\n    title: str = Field(min_length=1)\n    body: str = Field(min_length=1)\n\n    class Meta:\n        pagination = Pagination(style=\"page_number\", page_size=20)\n```\n\n**Page-number pagination** (`style=\"page_number\"`):\n\n```bash\nGET /posts?page=2\n# → {\"count\": 150, \"pages\": 8, \"next\": \"...\", \"previous\": \"...\", \"results\": [...]}\n```\n\n**Cursor pagination** (`style=\"cursor\"`) — keyset-based, O(1) regardless of offset:\n\n```bash\nGET /posts\n# → {\"next\": \"\u003cbase64-cursor\u003e\", \"previous\": null, \"results\": [...]}\n\nGET /posts?cursor=\u003cbase64-cursor\u003e\n# → {\"next\": \"\u003cnext-cursor\u003e\", \"previous\": null, \"results\": [...]}\n```\n\n### Custom Queryset\n\nOverride the base queryset by defining a `queryset` method:\n\n```python\nfrom sqlalchemy import select\nfrom starlette.requests import Request\nfrom lightapi import RestEndpoint, Field\n\nclass PublishedArticleEndpoint(RestEndpoint):\n    title: str = Field(min_length=1)\n    published: bool = Field()\n\n    def queryset(self, request: Request):\n        cls = type(self)\n        return select(cls._model_class).where(cls._model_class.published == True)\n```\n\n`GET /publishedarticles` now returns only published articles, while `GET /publishedarticles/{id}` still retrieves any row by primary key.\n\n### Response Caching\n\nCache `GET` responses in Redis by setting `Meta.cache`:\n\n```python\nfrom lightapi import RestEndpoint, Cache, Field\n\nclass ProductEndpoint(RestEndpoint):\n    name: str = Field(min_length=1)\n    price: float = Field(ge=0)\n\n    class Meta:\n        cache = Cache(ttl=60)   # cache GET responses for 60 seconds\n```\n\n- Only `GET` (list and retrieve) responses are cached.\n- `POST`, `PUT`, `PATCH`, `DELETE` automatically invalidate the cache for that endpoint's key prefix.\n- If Redis is unreachable at `app.run()`, a `RuntimeWarning` is emitted and caching is silently skipped.\n\nSet the Redis URL via environment variable:\n\n```bash\nexport LIGHTAPI_REDIS_URL=\"redis://localhost:6379/0\"\n```\n\n### Middleware\n\nImplement `Middleware.process(request, response)`:\n\n- Called with `response=None` **before** the endpoint — return a `Response` to short-circuit.\n- Called with the endpoint's response **after** — modify and return it, or return the response unchanged.\n\n```python\nfrom starlette.requests import Request\nfrom starlette.responses import JSONResponse, Response\nfrom lightapi import LightApi, RestEndpoint, Field\nfrom lightapi.core import Middleware\n\nclass RateLimitMiddleware(Middleware):\n    def process(self, request: Request, response: Response | None) -\u003e Response | None:\n        if response is None:  # pre-processing\n            if request.headers.get(\"X-Rate-Limit-Exceeded\"):\n                return JSONResponse({\"detail\": \"rate limit exceeded\"}, status_code=429)\n        return response  # post-processing: passthrough\n\nclass MyEndpoint(RestEndpoint):\n    name: str = Field(min_length=1)\n\napp = LightApi(engine=engine, middlewares=[RateLimitMiddleware])\napp.register({\"/items\": MyEndpoint})\n```\n\nMiddlewares are applied in declaration order (pre-phase) and reversed (post-phase).\n\n### Database Reflection\n\nMap an existing database table without declaring columns:\n\n```python\nclass LegacyUserEndpoint(RestEndpoint):\n    class Meta:\n        reflect = True\n        table = \"legacy_users\"   # existing table name in the database\n```\n\nExtend an existing table with additional columns:\n\n```python\nclass ExtendedEndpoint(RestEndpoint):\n    new_field: str = Field(min_length=1)\n\n    class Meta:\n        reflect = \"partial\"\n        table = \"existing_table\"   # reflect + add new_field column\n```\n\n`ConfigurationError` is raised at `app.register()` time if the table does not exist.\n\n### YAML Configuration\n\nBoot `LightApi` from a YAML file using `LightApi.from_config()`. Two formats are\nsupported — pick whichever fits your project.\n\n#### Declarative format (recommended)\n\nDefine endpoints, fields, and all `Meta` options directly in YAML. No Python\n`RestEndpoint` classes required.\n\n```yaml\n# lightapi.yaml\ndatabase:\n  url: \"${DATABASE_URL}\"        # ${VAR} env-var substitution\n\ncors_origins:\n  - \"https://myapp.com\"\n\n# Global defaults applied to every endpoint unless overridden\ndefaults:\n  authentication:\n    backend: JWTAuthentication\n    permission: IsAuthenticated\n  pagination:\n    style: page_number\n    page_size: 20\n\nmiddleware:\n  - CORSMiddleware\n\nendpoints:\n  - route: /products\n    fields:\n      name:        { type: str, max_length: 200 }\n      price:       { type: float }\n      in_stock:    { type: bool, default: true }\n    meta:\n      methods: [GET, POST, PUT, DELETE]\n      filtering:\n        fields:   [in_stock]\n        ordering: [price]\n\n  - route: /orders\n    fields:\n      reference: { type: str }\n      total:     { type: float }\n    meta:\n      methods: [GET, POST]\n      # Override the global default for this endpoint only\n      authentication:\n        permission: AllowAny\n```\n\n```python\nfrom lightapi import LightApi\n\napp = LightApi.from_config(\"lightapi.yaml\")\napp.run()\n```\n\n#### YAML field reference\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `database.url` | string | SQLAlchemy URL. Supports `${VAR}` env substitution. |\n| `cors_origins` | list | CORS allowed origins. |\n| `defaults.authentication` | object | `backend` + `permission` applied to every endpoint. |\n| `defaults.pagination` | object | `style` + `page_size` applied to every endpoint. |\n| `middleware` | list | Class names or dotted paths resolved at startup. |\n| `endpoints[].route` | string | URL prefix. |\n| `endpoints[].fields` | object | Inline field definitions — `type`, constraints, `optional`. |\n| `endpoints[].meta.methods` | list or dict | HTTP methods to enable; dict form allows per-method auth. |\n| `endpoints[].meta.authentication` | object | Overrides `defaults.authentication` for this endpoint. |\n| `endpoints[].meta.filtering` | object | `fields`, `search`, `ordering` lists. |\n| `endpoints[].meta.pagination` | object | `style` + `page_size` for this endpoint. |\n| `endpoints[].reflect` | bool | Reflect an existing table — no fields needed. |\n\nValidation is performed by Pydantic v2 at load time. Any schema error raises a\n`ConfigurationError` with a precise message pointing to the offending field.\n\n---\n\n## Async Support\n\nLightAPI's async support is **opt-in** and activated by a single change: passing a `create_async_engine` instead of `create_engine`. Everything else — filtering, pagination, serialization, middleware, caching — continues to work unchanged.\n\n### Enabling Async I/O\n\n```bash\nuv add \"lightapi[async]\"   # adds sqlalchemy[asyncio], asyncpg, aiosqlite, greenlet\n```\n\n```python\n# sync — existing code, no changes required\nfrom sqlalchemy import create_engine\nengine = create_engine(\"postgresql://user:pass@localhost/db\")\n\n# async — one-line swap\nfrom sqlalchemy.ext.asyncio import create_async_engine\nengine = create_async_engine(\"postgresql+asyncpg://user:pass@localhost/db\")\n```\n\nOnce an `AsyncEngine` is detected, LightAPI:\n\n- Uses `AsyncSession` for every request\n- Awaits `async def queryset`, `async def get/post/put/patch/delete` overrides\n- Falls back to sync CRUD for endpoints that still define sync methods\n- Runs `metadata.create_all` inside the server's event loop via Starlette `on_startup`\n- Validates that the async driver (e.g. `asyncpg`, `aiosqlite`) is installed at startup\n\n### Async Queryset\n\nDefine `async def queryset` to scope the base query asynchronously:\n\n```python\nfrom sqlalchemy import select\nfrom starlette.requests import Request\nfrom lightapi import RestEndpoint, Field\n\nclass OrderEndpoint(RestEndpoint):\n    amount: float = Field(ge=0)\n    status: str = Field(default=\"pending\")\n\n    async def queryset(self, request: Request):\n        # e.g. scope to authenticated user\n        user_id = request.state.user[\"sub\"]\n        return (\n            select(type(self)._model_class)\n            .where(type(self)._model_class.owner_id == user_id)\n        )\n```\n\n`async def queryset` is automatically detected via `asyncio.iscoroutinefunction` and awaited. A plain `def queryset` continues to work on an async app without any changes.\n\n### Async Method Overrides\n\nOverride individual HTTP verbs with `async def`:\n\n```python\nclass ProductEndpoint(RestEndpoint):\n    name: str = Field(min_length=1)\n    price: float = Field(ge=0)\n\n    async def post(self, request: Request):\n        import json\n        data = json.loads(await request.body())\n        # custom pre-processing ...\n        return await self._create_async(data)\n\n    async def get(self, request: Request):\n        # custom query, external call, etc.\n        return await self._list_async(request)\n```\n\n**Built-in async CRUD helpers** available on every `RestEndpoint`:\n\n| Method | Description |\n|---|---|\n| `await self._list_async(request)` | Paginated list |\n| `await self._retrieve_async(request, pk)` | Single row by PK |\n| `await self._create_async(data)` | Insert, flush, refresh |\n| `await self._update_async(data, pk, partial=False)` | Optimistic-lock update |\n| `await self._destroy_async(request, pk)` | Delete |\n\n### Background Tasks\n\nCall `self.background(fn, *args, **kwargs)` inside any async method override to schedule a fire-and-forget task. The task runs after the HTTP response is sent (Starlette `BackgroundTasks`):\n\n```python\nasync def notify(order_id: int) -\u003e None:\n    # send email, write audit log, push notification …\n    ...\n\nclass OrderEndpoint(RestEndpoint):\n    amount: float = Field(ge=0)\n\n    async def post(self, request: Request):\n        import json\n        resp = await self._create_async(json.loads(await request.body()))\n        if resp.status_code == 201:\n            import json as _json\n            self.background(notify, _json.loads(resp.body)[\"id\"])\n        return resp\n```\n\nBoth `def` (sync) and `async def` callables are accepted by Starlette's `BackgroundTasks`. Calling `self.background()` outside a request handler raises `RuntimeError`.\n\n### Async Middleware\n\n`Middleware.process` can be a coroutine — LightAPI awaits it automatically. Sync and async middleware coexist in the same list:\n\n```python\nfrom lightapi.core import Middleware\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\nclass AsyncAuditMiddleware(Middleware):\n    async def process(self, request: Request, response: Response | None) -\u003e None:\n        if response is None:\n            await write_audit_log(request)   # async I/O\n        return None\n\nclass SyncHeaderMiddleware(Middleware):\n    def process(self, request: Request, response: Response | None) -\u003e None:\n        if response is not None:\n            response.headers[\"X-Served-By\"] = \"lightapi\"\n        return None\n\napp = LightApi(engine=engine, middlewares=[AsyncAuditMiddleware, SyncHeaderMiddleware])\n```\n\nPre-processing order: `AsyncAuditMiddleware → SyncHeaderMiddleware`.\nPost-processing order (reversed): `SyncHeaderMiddleware → AsyncAuditMiddleware`.\n\n### Sync Endpoints on an Async App\n\nEndpoints that still define sync methods work without modification on an async-engine app:\n\n```python\nclass TagEndpoint(RestEndpoint):\n    label: str = Field(min_length=1)\n\n    def queryset(self, request: Request):          # sync — still works\n        return select(type(self)._model_class)\n```\n\nLightAPI detects whether `queryset` / the override method is async and dispatches accordingly. No runtime penalty on the sync path.\n\n### Session Helpers\n\n`get_sync_session` and `get_async_session` are exported from `lightapi` for use in custom code:\n\n```python\nfrom lightapi import get_sync_session, get_async_session\n\n# Sync\nwith get_sync_session(engine) as session:\n    rows = session.execute(select(MyModel)).scalars().all()\n\n# Async\nasync with get_async_session(async_engine) as session:\n    rows = (await session.execute(select(MyModel))).scalars().all()\n```\n\nBoth context managers commit on clean exit and roll back on exception.\n\n### Testing Async Endpoints\n\nUse `pytest-asyncio` and `httpx.AsyncClient` with an in-memory `aiosqlite` engine:\n\n```python\nimport pytest\nimport pytest_asyncio\nfrom httpx import ASGITransport, AsyncClient\nfrom sqlalchemy.ext.asyncio import create_async_engine\nfrom lightapi import LightApi, RestEndpoint\nfrom lightapi.auth import AllowAny\nfrom lightapi.config import Authentication\nfrom pydantic import Field\n\n@pytest_asyncio.fixture\nasync def client():\n    engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\")\n\n    class Widget(RestEndpoint):\n        name: str = Field(min_length=1)\n        class Meta:\n            authentication = Authentication(permission=AllowAny)\n\n    app = LightApi(engine=engine)\n    app.register({\"/widgets\": Widget})\n    async with AsyncClient(\n        transport=ASGITransport(app=app.build_app()), base_url=\"http://test\"\n    ) as c:\n        yield c\n\nasync def test_create_widget(client):\n    r = await client.post(\"/widgets\", json={\"name\": \"bolt\"})\n    assert r.status_code == 201\n    assert r.json()[\"name\"] == \"bolt\"\n```\n\nAdd to `pytest.ini`:\n\n```ini\n[pytest]\nasyncio_mode = auto\n```\n\n---\n\n## API Reference\n\n### `LightApi`\n\n```python\nLightApi(\n    engine=None,           # SQLAlchemy engine (takes priority over database_url)\n    database_url=None,     # Fallback: create_engine(database_url)\n    cors_origins=None,     # List[str] of allowed CORS origins\n    middlewares=None,      # List[type] of Middleware subclasses\n)\n```\n\n| Method | Description |\n|---|---|\n| `register(mapping)` | `{\"/path\": EndpointClass, ...}` — register endpoints and build routes |\n| `build_app()` | Create tables and return the Starlette ASGI app (for testing) |\n| `run(host, port, debug, reload)` | Create tables, check caches, start uvicorn |\n| `LightApi.from_config(path)` | Class method — construct from a YAML file |\n\n### `RestEndpoint`\n\n| Attribute | Type | Description |\n|---|---|---|\n| `_meta` | `dict` | Parsed Meta configuration |\n| `_allowed_methods` | `set[str]` | HTTP verbs this endpoint handles |\n| `_model_class` | `type` | SQLAlchemy-mapped class (same as `type(self)`) |\n| `__schema_create__` | `ModelMetaclass` | Pydantic model for POST/PUT/PATCH input |\n| `__schema_read__` | `ModelMetaclass` | Pydantic model for responses |\n\nOverride these methods to customise behaviour. Both `def` (sync) and `async def` (async) variants are detected automatically:\n\n| Method | Signature | Default behaviour |\n|---|---|---|\n| `list` | `(request)` | `SELECT *` + optional filter/pagination |\n| `retrieve` | `(request, pk)` | `SELECT WHERE id=pk` |\n| `create` | `(data)` | `INSERT RETURNING` |\n| `update` | `(data, pk, partial)` | `UPDATE WHERE id=pk AND version=N RETURNING` |\n| `destroy` | `(request, pk)` | `DELETE WHERE id=pk` |\n| `queryset` | `(request)` | Returns base `select(cls._model_class)` |\n| `get` | `(request)` | Override GET (collection or detail) |\n| `post` | `(request)` | Override POST |\n| `put` | `(request)` | Override PUT |\n| `patch` | `(request)` | Override PATCH |\n| `delete` | `(request)` | Override DELETE |\n\n**Async CRUD helpers** (available when using an async engine):\n\n| Helper | Description |\n|---|---|\n| `_list_async(request)` | Async `SELECT *` with pagination |\n| `_retrieve_async(request, pk)` | Async `SELECT WHERE id=pk` |\n| `_create_async(data)` | Async `INSERT` with flush/refresh |\n| `_update_async(data, pk, partial)` | Async optimistic-lock `UPDATE` |\n| `_destroy_async(request, pk)` | Async `DELETE` |\n| `background(fn, *args, **kwargs)` | Schedule a post-response background task |\n\n### `Meta` inner class\n\n```python\nclass MyEndpoint(RestEndpoint):\n    class Meta:\n        authentication = Authentication(backend=..., permission=...)\n        filtering = Filtering(backends=[...], fields=[...], search=[...], ordering=[...])\n        pagination = Pagination(style=\"page_number\"|\"cursor\", page_size=20)\n        serializer = Serializer(fields=[...]) | Serializer(read=[...], write=[...])\n        cache = Cache(ttl=60)\n        reflect = False | True | \"partial\"\n        table = \"custom_table_name\"     # overrides derived name\n```\n\n### Error responses\n\n| Scenario | Status code | Body |\n|---|---|---|\n| Validation failure | `422` | `{\"detail\": [...pydantic errors...]}` |\n| Not found | `404` | `{\"detail\": \"not found\"}` |\n| Optimistic lock conflict | `409` | `{\"detail\": \"version conflict\"}` |\n| Auth failure | `401` | `{\"detail\": \"Authentication credentials invalid.\"}` |\n| Permission denied | `403` | `{\"detail\": \"You do not have permission to perform this action.\"}` |\n| Method not registered | `405` | `{\"detail\": \"Method Not Allowed. Allowed: GET, POST\"}` |\n\n---\n\n## Testing\n\n```bash\n# Install with dev extras\nuv add -e \".[dev]\"\n\n# Run all tests (sync + async)\npytest tests/\n\n# Run only async-related tests\npytest tests/test_async_crud.py tests/test_async_session.py \\\n       tests/test_async_queryset.py tests/test_async_middleware.py \\\n       tests/test_background_tasks.py tests/test_mixed_sync_async.py \\\n       tests/test_async_reflection.py\n\n# Run with coverage\npytest tests/ --cov=lightapi --cov-report=term-missing\n```\n\n**Async test setup** — add to `pytest.ini`:\n\n```ini\n[pytest]\nasyncio_mode = auto\n```\n\nFor sync SQLite in-memory databases in tests, use `StaticPool` to share a single connection:\n\n```python\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.pool import StaticPool\nfrom starlette.testclient import TestClient\nfrom lightapi import LightApi, RestEndpoint, Field\n\nclass ItemEndpoint(RestEndpoint):\n    name: str = Field(min_length=1)\n\nengine = create_engine(\n    \"sqlite:///:memory:\",\n    connect_args={\"check_same_thread\": False},\n    poolclass=StaticPool,\n)\napp_instance = LightApi(engine=engine)\napp_instance.register({\"/items\": ItemEndpoint})\nclient = TestClient(app_instance.build_app())\n```\n\n---\n\n## Configuration\n\n### Environment variables\n\n| Variable | Default | Description |\n|---|---|---|\n| `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. |\n| `LIGHTAPI_JWT_SECRET` | — | Required for `JWTAuthentication` |\n| `LIGHTAPI_REDIS_URL` | `redis://localhost:6379/0` | Redis URL for response caching |\n\n### Docker\n\n```dockerfile\nFROM python:3.12-slim\nWORKDIR /app\nCOPY pyproject.toml .\nRUN pip install uv \u0026\u0026 uv pip install --system -e .\nCOPY . .\nEXPOSE 8000\nCMD [\"python\", \"-m\", \"uvicorn\", \"main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n```\n\n```yaml\n# docker-compose.yml\nservices:\n  api:\n    build: .\n    ports: [\"8000:8000\"]\n    environment:\n      LIGHTAPI_DATABASE_URL: postgresql://postgres:pass@db:5432/mydb\n      LIGHTAPI_JWT_SECRET: change-me-in-production\n      LIGHTAPI_REDIS_URL: redis://redis:6379/0\n    depends_on: [db, redis]\n  db:\n    image: postgres:16-alpine\n    environment: {POSTGRES_DB: mydb, POSTGRES_USER: postgres, POSTGRES_PASSWORD: pass}\n  redis:\n    image: redis:7-alpine\n```\n\n---\n\n## Contributing\n\n```bash\ngit clone https://github.com/iklobato/lightapi.git\ncd lightapi\nuv venv .venv \u0026\u0026 source .venv/bin/activate\nuv pip install -e \".[dev]\"\n\n# Run tests\npytest tests/\n\n# Lint and format\nruff check lightapi/\nruff format lightapi/\n\n# Type check\nmypy lightapi/\n```\n\nGuidelines:\n1. Fork the repository and create a feature branch\n2. Write tests for new features — all existing tests must remain green\n3. Follow the existing code style (PEP 8, type hints everywhere)\n4. Submit a pull request with a clear description of the change\n\nBug reports: Please open a GitHub issue with Python version, LightAPI version, a minimal reproduction, and the full traceback.\n\n---\n\n## License\n\nLightAPI is released under the MIT License. See [LICENSE](LICENSE) for details.\n\n---\n\n## Acknowledgments\n\n- **Starlette** — ASGI framework and routing\n- **SQLAlchemy 2.0** — ORM and imperative mapping\n- **Pydantic v2** — Data validation and schema generation\n- **Uvicorn** — ASGI server\n- **PyJWT** — JWT token handling\n\n---\n\n**Get started:**\n\n```bash\nuv pip install lightapi\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fiklobato%2Flightapi","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fiklobato%2Flightapi","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fiklobato%2Flightapi/lists"}