A lightweight framework for building API endpoints using Python's native libraries.
Project description
LightAPI v2: Annotation-Driven Python REST Framework
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.
Table of Contents
- Why LightAPI v2?
- Installation
- Quick Start
- Core Concepts
- Async Support
- API Reference
- Testing
- Contributing
- License
Why LightAPI v2?
- One class, three roles: Your
RestEndpointsubclass is the SQLAlchemy ORM model, the Pydantic v2 schema, and the HTTP handler — no separate files, no boilerplate. - Annotation-driven columns: Write
title: str = Field(min_length=1)— LightAPI creates theVARCHARcolumn, the Pydantic constraint, and the API validation all at once. - Optimistic locking built in: Every endpoint gets a
versionfield.PUT/PATCHrequireversionin the body; mismatches return409 Conflict. - Opt-in async I/O: Swap
create_engineforcreate_async_engine— LightAPI automatically usesAsyncSessionfor every request. Sync and async endpoints coexist on the same app instance. - No aiohttp: Pure Starlette + Uvicorn ASGI stack, no async framework mixing.
- Pydantic v2: Full
model_validate,model_dump(mode='json'),ConfigDictcompatibility. - SQLAlchemy 2.0 imperative mapping: No
DeclarativeBaseinheritance required.
Installation
# Using uv (recommended)
uv add lightapi
# Or pip
pip install lightapi
Requirements: Python 3.10+, SQLAlchemy 2.x, Pydantic v2, Starlette, Uvicorn.
Optional async I/O (PostgreSQL / SQLite async):
# asyncpg (PostgreSQL async driver)
uv add "lightapi[async]"
# installs: sqlalchemy[asyncio], asyncpg, aiosqlite, greenlet
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.
Quick Start
from sqlalchemy import create_engine
from lightapi import LightApi, RestEndpoint, Field
class BookEndpoint(RestEndpoint):
title: str = Field(min_length=1)
author: str = Field(min_length=1)
engine = create_engine("sqlite:///books.db")
app = LightApi(engine=engine)
app.register({"/books": BookEndpoint})
if __name__ == "__main__":
app.run()
That's it. You now have:
| Method | URL | Description |
|---|---|---|
GET |
/books |
List all books ({"results": [...]}) |
POST |
/books |
Create a book (validates title min_length=1) |
GET |
/books/{id} |
Retrieve one book |
PUT |
/books/{id} |
Full update (requires version) |
PATCH |
/books/{id} |
Partial update (requires version) |
DELETE |
/books/{id} |
Delete (returns 204) |
# Create
curl -X POST http://localhost:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Clean Code", "author": "Robert Martin"}'
# → 201 {"id": 1, "title": "Clean Code", "author": "Robert Martin", "version": 1, ...}
# Update (must supply version)
curl -X PUT http://localhost:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Clean Code (2nd Ed)", "author": "Robert Martin", "version": 1}'
# → 200 {"id": 1, "version": 2, ...}
# Stale version
curl -X PUT http://localhost:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Clash", "author": "X", "version": 1}'
# → 409 {"detail": "version conflict"}
Core Concepts
RestEndpoint and Field
Declare fields using Python type annotations and Field():
from lightapi import RestEndpoint, Field
from typing import Optional
from decimal import Decimal
class ProductEndpoint(RestEndpoint):
name: str = Field(min_length=1, max_length=200)
price: Decimal = Field(ge=0, decimal_places=2)
category: str = Field(min_length=1)
description: Optional[str] = None # nullable column, no constraint
in_stock: bool = Field(default=True)
Supported types and their SQLAlchemy column mappings:
| Python annotation | Column type | Nullable |
|---|---|---|
str |
VARCHAR |
No |
Optional[str] |
VARCHAR |
Yes |
int |
INTEGER |
No |
Optional[int] |
INTEGER |
Yes |
float |
FLOAT |
No |
bool |
BOOLEAN |
No |
datetime |
DATETIME |
No |
Decimal |
NUMERIC(scale=N) |
No |
UUID |
UUID |
No |
LightAPI-specific Field() kwargs (stored in json_schema_extra, not passed to Pydantic):
| Kwarg | Effect |
|---|---|
foreign_key="table.col" |
Adds ForeignKey constraint on the column |
unique=True |
Adds UNIQUE constraint |
index=True |
Adds a database index |
exclude=True |
Column is skipped entirely (no DB column, no schema field) |
decimal_places=N |
Sets Numeric(scale=N) (used with Decimal type) |
Auto-injected Columns
Every RestEndpoint subclass automatically gets these columns — you never declare them:
| Column | Type | Default |
|---|---|---|
id |
Integer PK |
autoincrement |
created_at |
DateTime |
utcnow on insert |
updated_at |
DateTime |
utcnow on insert + update |
version |
Integer |
1 on insert, incremented on each PUT/PATCH |
id, created_at, updated_at, and version are excluded from the create/update input schema but included in all responses.
Optimistic Locking
Every PUT and PATCH request must include version in the JSON body:
# First fetch the current version
curl http://localhost:8000/products/42
# → {"id": 42, "name": "Widget", "version": 3, ...}
# Update with correct version
curl -X PATCH http://localhost:8000/products/42 \
-H "Content-Type: application/json" \
-d '{"name": "Super Widget", "version": 3}'
# → 200 {"id": 42, "name": "Super Widget", "version": 4, ...}
# Concurrent update with stale version → conflict
curl -X PATCH http://localhost:8000/products/42 \
-H "Content-Type: application/json" \
-d '{"name": "Other Widget", "version": 3}'
# → 409 {"detail": "version conflict"}
Missing version returns 422 Unprocessable Entity.
HttpMethod Mixins
Control which HTTP verbs your endpoint exposes by mixing in HttpMethod.* classes:
from lightapi import RestEndpoint, HttpMethod, Field
class ReadOnlyEndpoint(RestEndpoint, HttpMethod.GET):
"""Only GET /items and GET /items/{id} are registered."""
name: str = Field(min_length=1)
class CreateOnlyEndpoint(RestEndpoint, HttpMethod.POST):
"""Only POST /items is registered."""
name: str = Field(min_length=1)
class StandardEndpoint(RestEndpoint, HttpMethod.GET, HttpMethod.POST,
HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE):
"""Explicit full CRUD — same as the default with no mixins."""
name: str = Field(min_length=1)
Unregistered methods return 405 Method Not Allowed with an Allow header.
Serializer
Control which fields appear in responses, globally or per-verb:
from lightapi import RestEndpoint, Serializer, Field
# Form 1 — all verbs, all fields (default)
class Ep1(RestEndpoint):
name: str = Field(min_length=1)
# Form 2 — restrict to a subset for all verbs
class Ep2(RestEndpoint):
name: str = Field(min_length=1)
internal_code: str = Field(min_length=1)
class Meta:
serializer = Serializer(fields=["id", "name"])
# Form 3 — different fields for reads vs writes
class Ep3(RestEndpoint):
name: str = Field(min_length=1)
class Meta:
serializer = Serializer(
read=["id", "name", "created_at", "version"],
write=["id", "name"],
)
# Form 4 — reusable subclass, shared across endpoints
class PublicSerializer(Serializer):
read = ["id", "name", "created_at"]
write = ["id", "name"]
class Ep4(RestEndpoint):
name: str = Field(min_length=1)
class Meta:
serializer = PublicSerializer
class Ep5(RestEndpoint):
name: str = Field(min_length=1)
class Meta:
serializer = PublicSerializer # reused
Authentication and Permissions
Use Meta.authentication with a backend and an optional permission class:
import os
from lightapi import RestEndpoint, Authentication, Field
from lightapi import JWTAuthentication, IsAuthenticated, IsAdminUser
os.environ["LIGHTAPI_JWT_SECRET"] = "your-secret-key"
class ProtectedEndpoint(RestEndpoint):
secret: str = Field(min_length=1)
class Meta:
authentication = Authentication(backend=JWTAuthentication)
class AdminOnlyEndpoint(RestEndpoint):
data: str = Field(min_length=1)
class Meta:
authentication = Authentication(
backend=JWTAuthentication,
permission=IsAdminUser, # requires payload["is_admin"] == True
)
Request flow:
JWTAuthentication.authenticate(request)— extracts and validatesAuthorization: Bearer <token>, stores payload inrequest.state.user- Permission class
.has_permission(request)— checksrequest.state.user - Returns
401if authentication fails,403if permission denied
Built-in permission classes:
| Class | Condition |
|---|---|
AllowAny |
Always allowed (default) |
IsAuthenticated |
request.state.user is not None |
IsAdminUser |
request.state.user["is_admin"] == True |
Filtering, Search, and Ordering
Declare filter backends and allowed fields in Meta.filtering:
from lightapi import RestEndpoint, Filtering, Field
from lightapi.filters import FieldFilter, SearchFilter, OrderingFilter
class ArticleEndpoint(RestEndpoint):
title: str = Field(min_length=1)
category: str = Field(min_length=1)
author: str = Field(min_length=1)
class Meta:
filtering = Filtering(
backends=[FieldFilter, SearchFilter, OrderingFilter],
fields=["category"], # ?category=news (exact match)
search=["title", "author"], # ?search=python (case-insensitive LIKE)
ordering=["title", "author"], # ?ordering=title or ?ordering=-title
)
Query parameters:
# Exact filter (whitelisted fields only)
GET /articles?category=news
# Full-text search across title and author
GET /articles?search=python
# Ordering (prefix - for descending)
GET /articles?ordering=-title
# Combine all
GET /articles?category=news&search=python&ordering=-title
Pagination
from lightapi import RestEndpoint, Pagination, Field
class PostEndpoint(RestEndpoint):
title: str = Field(min_length=1)
body: str = Field(min_length=1)
class Meta:
pagination = Pagination(style="page_number", page_size=20)
Page-number pagination (style="page_number"):
GET /posts?page=2
# → {"count": 150, "pages": 8, "next": "...", "previous": "...", "results": [...]}
Cursor pagination (style="cursor") — keyset-based, O(1) regardless of offset:
GET /posts
# → {"next": "<base64-cursor>", "previous": null, "results": [...]}
GET /posts?cursor=<base64-cursor>
# → {"next": "<next-cursor>", "previous": null, "results": [...]}
Custom Queryset
Override the base queryset by defining a queryset method:
from sqlalchemy import select
from starlette.requests import Request
from lightapi import RestEndpoint, Field
class PublishedArticleEndpoint(RestEndpoint):
title: str = Field(min_length=1)
published: bool = Field()
def queryset(self, request: Request):
cls = type(self)
return select(cls._model_class).where(cls._model_class.published == True)
GET /publishedarticles now returns only published articles, while GET /publishedarticles/{id} still retrieves any row by primary key.
Response Caching
Cache GET responses in Redis by setting Meta.cache:
from lightapi import RestEndpoint, Cache, Field
class ProductEndpoint(RestEndpoint):
name: str = Field(min_length=1)
price: float = Field(ge=0)
class Meta:
cache = Cache(ttl=60) # cache GET responses for 60 seconds
- Only
GET(list and retrieve) responses are cached. POST,PUT,PATCH,DELETEautomatically invalidate the cache for that endpoint's key prefix.- If Redis is unreachable at
app.run(), aRuntimeWarningis emitted and caching is silently skipped.
Set the Redis URL via environment variable:
export LIGHTAPI_REDIS_URL="redis://localhost:6379/0"
Middleware
Implement Middleware.process(request, response):
- Called with
response=Nonebefore the endpoint — return aResponseto short-circuit. - Called with the endpoint's response after — modify and return it, or return the response unchanged.
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from lightapi import LightApi, RestEndpoint, Field
from lightapi.core import Middleware
class RateLimitMiddleware(Middleware):
def process(self, request: Request, response: Response | None) -> Response | None:
if response is None: # pre-processing
if request.headers.get("X-Rate-Limit-Exceeded"):
return JSONResponse({"detail": "rate limit exceeded"}, status_code=429)
return response # post-processing: passthrough
class MyEndpoint(RestEndpoint):
name: str = Field(min_length=1)
app = LightApi(engine=engine, middlewares=[RateLimitMiddleware])
app.register({"/items": MyEndpoint})
Middlewares are applied in declaration order (pre-phase) and reversed (post-phase).
Database Reflection
Map an existing database table without declaring columns:
class LegacyUserEndpoint(RestEndpoint):
class Meta:
reflect = True
table = "legacy_users" # existing table name in the database
Extend an existing table with additional columns:
class ExtendedEndpoint(RestEndpoint):
new_field: str = Field(min_length=1)
class Meta:
reflect = "partial"
table = "existing_table" # reflect + add new_field column
ConfigurationError is raised at app.register() time if the table does not exist.
YAML Configuration
Boot LightApi from a YAML file using LightApi.from_config(). Two formats are
supported — pick whichever fits your project.
Declarative format (recommended)
Define endpoints, fields, and all Meta options directly in YAML. No Python
RestEndpoint classes required.
# lightapi.yaml
database:
url: "${DATABASE_URL}" # ${VAR} env-var substitution
cors_origins:
- "https://myapp.com"
# Global defaults applied to every endpoint unless overridden
defaults:
authentication:
backend: JWTAuthentication
permission: IsAuthenticated
pagination:
style: page_number
page_size: 20
middleware:
- CORSMiddleware
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]
- route: /orders
fields:
reference: { type: str }
total: { type: float }
meta:
methods: [GET, POST]
# Override the global default for this endpoint only
authentication:
permission: AllowAny
from lightapi import LightApi
app = LightApi.from_config("lightapi.yaml")
app.run()
Legacy format (existing classes)
Point to pre-existing RestEndpoint subclasses by dotted import path:
# lightapi.yaml
database_url: "${DATABASE_URL}"
cors_origins:
- "https://myapp.com"
endpoints:
- path: /products
class: myapp.endpoints.ProductEndpoint
- path: /orders
class: myapp.endpoints.OrderEndpoint
YAML field reference
| Field | Type | Description |
|---|---|---|
database.url |
string | SQLAlchemy URL (nested form). Supports ${VAR}. |
database_url |
string | SQLAlchemy URL (legacy flat form). Supports ${VAR}. |
cors_origins |
list | CORS allowed origins. |
defaults.authentication |
object | backend + permission applied to every endpoint. |
defaults.pagination |
object | style + page_size applied to every endpoint. |
middleware |
list | Class names or dotted paths resolved at startup. |
endpoints[].route |
string | URL prefix (declarative format). |
endpoints[].fields |
object | Inline field definitions — type, constraints, optional. |
endpoints[].meta.methods |
list or dict | HTTP methods to enable; dict form allows per-method auth. |
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. |
endpoints[].reflect |
bool | Reflect an existing table — no fields needed. |
endpoints[].path |
string | URL prefix (legacy format). |
endpoints[].class |
string | Dotted import path to a RestEndpoint subclass (legacy). |
Validation is performed by Pydantic v2 at load time. Any schema error raises a
ConfigurationError with a precise message pointing to the offending field.
Async Support
LightAPI'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.
Enabling Async I/O
uv add "lightapi[async]" # adds sqlalchemy[asyncio], asyncpg, aiosqlite, greenlet
# sync — existing code, no changes required
from sqlalchemy import create_engine
engine = create_engine("postgresql://user:pass@localhost/db")
# async — one-line swap
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
Once an AsyncEngine is detected, LightAPI:
- Uses
AsyncSessionfor every request - Awaits
async def queryset,async def get/post/put/patch/deleteoverrides - Falls back to sync CRUD for endpoints that still define sync methods
- Runs
metadata.create_allinside the server's event loop via Starletteon_startup - Validates that the async driver (e.g.
asyncpg,aiosqlite) is installed at startup
Async Queryset
Define async def queryset to scope the base query asynchronously:
from sqlalchemy import select
from starlette.requests import Request
from lightapi import RestEndpoint, Field
class OrderEndpoint(RestEndpoint):
amount: float = Field(ge=0)
status: str = Field(default="pending")
async def queryset(self, request: Request):
# e.g. scope to authenticated user
user_id = request.state.user["sub"]
return (
select(type(self)._model_class)
.where(type(self)._model_class.owner_id == user_id)
)
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.
Async Method Overrides
Override individual HTTP verbs with async def:
class ProductEndpoint(RestEndpoint):
name: str = Field(min_length=1)
price: float = Field(ge=0)
async def post(self, request: Request):
import json
data = json.loads(await request.body())
# custom pre-processing ...
return await self._create_async(data)
async def get(self, request: Request):
# custom query, external call, etc.
return await self._list_async(request)
Built-in async CRUD helpers available on every RestEndpoint:
| Method | Description |
|---|---|
await self._list_async(request) |
Paginated list |
await self._retrieve_async(request, pk) |
Single row by PK |
await self._create_async(data) |
Insert, flush, refresh |
await self._update_async(data, pk, partial=False) |
Optimistic-lock update |
await self._destroy_async(request, pk) |
Delete |
Background Tasks
Call 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):
async def notify(order_id: int) -> None:
# send email, write audit log, push notification …
...
class OrderEndpoint(RestEndpoint):
amount: float = Field(ge=0)
async def post(self, request: Request):
import json
resp = await self._create_async(json.loads(await request.body()))
if resp.status_code == 201:
import json as _json
self.background(notify, _json.loads(resp.body)["id"])
return resp
Both def (sync) and async def callables are accepted by Starlette's BackgroundTasks. Calling self.background() outside a request handler raises RuntimeError.
Async Middleware
Middleware.process can be a coroutine — LightAPI awaits it automatically. Sync and async middleware coexist in the same list:
from lightapi.core import Middleware
from starlette.requests import Request
from starlette.responses import Response
class AsyncAuditMiddleware(Middleware):
async def process(self, request: Request, response: Response | None) -> None:
if response is None:
await write_audit_log(request) # async I/O
return None
class SyncHeaderMiddleware(Middleware):
def process(self, request: Request, response: Response | None) -> None:
if response is not None:
response.headers["X-Served-By"] = "lightapi"
return None
app = LightApi(engine=engine, middlewares=[AsyncAuditMiddleware, SyncHeaderMiddleware])
Pre-processing order: AsyncAuditMiddleware → SyncHeaderMiddleware.
Post-processing order (reversed): SyncHeaderMiddleware → AsyncAuditMiddleware.
Sync Endpoints on an Async App
Endpoints that still define sync methods work without modification on an async-engine app:
class TagEndpoint(RestEndpoint):
label: str = Field(min_length=1)
def queryset(self, request: Request): # sync — still works
return select(type(self)._model_class)
LightAPI detects whether queryset / the override method is async and dispatches accordingly. No runtime penalty on the sync path.
Session Helpers
get_sync_session and get_async_session are exported from lightapi for use in custom code:
from lightapi import get_sync_session, get_async_session
# Sync
with get_sync_session(engine) as session:
rows = session.execute(select(MyModel)).scalars().all()
# Async
async with get_async_session(async_engine) as session:
rows = (await session.execute(select(MyModel))).scalars().all()
Both context managers commit on clean exit and roll back on exception.
Testing Async Endpoints
Use pytest-asyncio and httpx.AsyncClient with an in-memory aiosqlite engine:
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine
from lightapi import LightApi, RestEndpoint
from lightapi.auth import AllowAny
from lightapi.config import Authentication
from pydantic import Field
@pytest_asyncio.fixture
async def client():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
class Widget(RestEndpoint):
name: str = Field(min_length=1)
class Meta:
authentication = Authentication(permission=AllowAny)
app = LightApi(engine=engine)
app.register({"/widgets": Widget})
async with AsyncClient(
transport=ASGITransport(app=app.build_app()), base_url="http://test"
) as c:
yield c
async def test_create_widget(client):
r = await client.post("/widgets", json={"name": "bolt"})
assert r.status_code == 201
assert r.json()["name"] == "bolt"
Add to pytest.ini:
[pytest]
asyncio_mode = auto
API Reference
LightApi
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
)
| Method | 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 |
RestEndpoint
| Attribute | Type | Description |
|---|---|---|
_meta |
dict |
Parsed Meta configuration |
_allowed_methods |
set[str] |
HTTP verbs this endpoint handles |
_model_class |
type |
SQLAlchemy-mapped class (same as type(self)) |
__schema_create__ |
ModelMetaclass |
Pydantic model for POST/PUT/PATCH input |
__schema_read__ |
ModelMetaclass |
Pydantic model for responses |
Override these methods to customise behaviour. Both def (sync) and async def (async) variants are detected automatically:
| Method | Signature | Default behaviour |
|---|---|---|
list |
(request) |
SELECT * + optional filter/pagination |
retrieve |
(request, pk) |
SELECT WHERE id=pk |
create |
(data) |
INSERT RETURNING |
update |
(data, pk, partial) |
UPDATE WHERE id=pk AND version=N RETURNING |
destroy |
(request, pk) |
DELETE WHERE id=pk |
queryset |
(request) |
Returns base select(cls._model_class) |
get |
(request) |
Override GET (collection or detail) |
post |
(request) |
Override POST |
put |
(request) |
Override PUT |
patch |
(request) |
Override PATCH |
delete |
(request) |
Override DELETE |
Async CRUD helpers (available when using an async engine):
| Helper | Description |
|---|---|
_list_async(request) |
Async SELECT * with pagination |
_retrieve_async(request, pk) |
Async SELECT WHERE id=pk |
_create_async(data) |
Async INSERT with flush/refresh |
_update_async(data, pk, partial) |
Async optimistic-lock UPDATE |
_destroy_async(request, pk) |
Async DELETE |
background(fn, *args, **kwargs) |
Schedule a post-response background task |
Meta inner class
class MyEndpoint(RestEndpoint):
class Meta:
authentication = Authentication(backend=..., permission=...)
filtering = Filtering(backends=[...], fields=[...], search=[...], ordering=[...])
pagination = Pagination(style="page_number"|"cursor", page_size=20)
serializer = Serializer(fields=[...]) | Serializer(read=[...], write=[...])
cache = Cache(ttl=60)
reflect = False | True | "partial"
table = "custom_table_name" # overrides derived name
Error responses
| Scenario | Status code | Body |
|---|---|---|
| Validation failure | 422 |
{"detail": [...pydantic errors...]} |
| Not found | 404 |
{"detail": "not found"} |
| Optimistic lock conflict | 409 |
{"detail": "version conflict"} |
| Auth failure | 401 |
{"detail": "Authentication credentials invalid."} |
| Permission denied | 403 |
{"detail": "You do not have permission to perform this action."} |
| Method not registered | 405 |
{"detail": "Method Not Allowed. Allowed: GET, POST"} |
Testing
# Install with dev extras
uv add -e ".[dev]"
# Run all tests (sync + async)
pytest tests/
# Run only async-related tests
pytest tests/test_async_crud.py tests/test_async_session.py \
tests/test_async_queryset.py tests/test_async_middleware.py \
tests/test_background_tasks.py tests/test_mixed_sync_async.py \
tests/test_async_reflection.py
# Run with coverage
pytest tests/ --cov=lightapi --cov-report=term-missing
Async test setup — add to pytest.ini:
[pytest]
asyncio_mode = auto
For sync SQLite in-memory databases in tests, use StaticPool to share a single connection:
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from starlette.testclient import TestClient
from lightapi import LightApi, RestEndpoint, Field
class ItemEndpoint(RestEndpoint):
name: str = Field(min_length=1)
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
app_instance = LightApi(engine=engine)
app_instance.register({"/items": ItemEndpoint})
client = TestClient(app_instance.build_app())
Configuration
Environment variables
| Variable | Default | Description |
|---|---|---|
LIGHTAPI_DATABASE_URL |
sqlite:///app.db |
Database connection URL |
LIGHTAPI_JWT_SECRET |
— | Required for JWTAuthentication |
LIGHTAPI_REDIS_URL |
redis://localhost:6379/0 |
Redis URL for response caching |
LIGHTAPI_HOST |
0.0.0.0 |
Uvicorn bind host |
LIGHTAPI_PORT |
8000 |
Uvicorn bind port |
LIGHTAPI_DEBUG |
false |
Enable debug mode |
Docker
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install uv && uv pip install --system -e .
COPY . .
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
services:
api:
build: .
ports: ["8000:8000"]
environment:
LIGHTAPI_DATABASE_URL: postgresql://postgres:pass@db:5432/mydb
LIGHTAPI_JWT_SECRET: change-me-in-production
LIGHTAPI_REDIS_URL: redis://redis:6379/0
depends_on: [db, redis]
db:
image: postgres:16-alpine
environment: {POSTGRES_DB: mydb, POSTGRES_USER: postgres, POSTGRES_PASSWORD: pass}
redis:
image: redis:7-alpine
Contributing
git clone https://github.com/iklobato/lightapi.git
cd lightapi
uv venv .venv && source .venv/bin/activate
uv pip install -e ".[dev]"
# Run tests
pytest tests/
# Lint and format
ruff check lightapi/
ruff format lightapi/
# Type check
mypy lightapi/
Guidelines:
- Fork the repository and create a feature branch
- Write tests for new features — all existing tests must remain green
- Follow the existing code style (PEP 8, type hints everywhere)
- Submit a pull request with a clear description of the change
Bug reports: Please open a GitHub issue with Python version, LightAPI version, a minimal reproduction, and the full traceback.
License
LightAPI is released under the MIT License. See LICENSE for details.
Acknowledgments
- Starlette — ASGI framework and routing
- SQLAlchemy 2.0 — ORM and imperative mapping
- Pydantic v2 — Data validation and schema generation
- Uvicorn — ASGI server
- PyJWT — JWT token handling
Get started:
uv pip install lightapi
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lightapi-0.1.17.tar.gz.
File metadata
- Download URL: lightapi-0.1.17.tar.gz
- Upload date:
- Size: 260.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59871ff8bf1cda6b651b3a962a73599a471d99aad0d18005c9770899e1c2bf4b
|
|
| MD5 |
effa0be57035f37ef8b6071d9ec59306
|
|
| BLAKE2b-256 |
2a88dc16673f1c2043029f71a93f8614e131d6af346cb33e81481461fa2417fd
|
File details
Details for the file lightapi-0.1.17-py3-none-any.whl.
File metadata
- Download URL: lightapi-0.1.17-py3-none-any.whl
- Upload date:
- Size: 60.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d22a237101050f80e5498cbf692f71c06b6f5cb013b5e0e1f3ed5db877620716
|
|
| MD5 |
42c0e5f195a33de2aa9192d7fc37e151
|
|
| BLAKE2b-256 |
0efe646cab6c5bafec7f264e21aa24edd8baf7d95ecfaab2b116b17587224ec2
|