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.
- Why LightAPI v2?
- Installation
- Quick Start
- Core Concepts
- Async Support
- API Reference
- Testing
- Contributing
- License
- 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.
# Using uv (recommended)
uv add lightapi
# Or pip
pip install lightapiRequirements: 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, greenletOptional 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:
docker run --rm -p 8000:8000 \
-v "$(pwd)/lightapi.yaml:/app/lightapi.yaml:ro" \
-e DATABASE_URL=sqlite:////app/data.db \
iklob1/lightapi:latestSee Docker deployment for the full guide.
fromsqlalchemyimportcreate_enginefromlightapiimportLightApi, RestEndpoint, FieldclassBookEndpoint(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"}Declare fields using Python type annotations and Field():
fromlightapiimportRestEndpoint, FieldfromtypingimportOptionalfromdecimalimportDecimalclassProductEndpoint(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 constraintin_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) |
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.
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.
Clearing Optional fields: Send an explicit
nullvalue in a PATCH body to clear a nullable (Optional[...]) field: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
nullvalues — they are treated as if the field was not sent.
Control which HTTP verbs your endpoint exposes by mixing in HttpMethod.* classes:
fromlightapiimportRestEndpoint, HttpMethod, FieldclassReadOnlyEndpoint(RestEndpoint, HttpMethod.GET):
"""Only GET /items and GET /items/{id} are registered."""name: str=Field(min_length=1)
classCreateOnlyEndpoint(RestEndpoint, HttpMethod.POST):
"""Only POST /items is registered."""name: str=Field(min_length=1)
classStandardEndpoint(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.
Control which fields appear in responses, globally or per-verb:
fromlightapiimportRestEndpoint, Serializer, Field# Form 1 — all verbs, all fields (default)classEp1(RestEndpoint):
name: str=Field(min_length=1)
# Form 2 — restrict to a subset for all verbsclassEp2(RestEndpoint):
name: str=Field(min_length=1)
internal_code: str=Field(min_length=1)
classMeta:
serializer=Serializer(fields=["id", "name"])
# Form 3 — different fields for reads vs writesclassEp3(RestEndpoint):
name: str=Field(min_length=1)
classMeta:
serializer=Serializer(
read=["id", "name", "created_at", "version"],
write=["id", "name"],
)
# Form 4 — reusable subclass, shared across endpointsclassPublicSerializer(Serializer):
read= ["id", "name", "created_at"]
write= ["id", "name"]
classEp4(RestEndpoint):
name: str=Field(min_length=1)
classMeta:
serializer=PublicSerializerclassEp5(RestEndpoint):
name: str=Field(min_length=1)
classMeta:
serializer=PublicSerializer# reusedUse Meta.authentication with a backend and an optional permission class:
importosfromlightapiimportRestEndpoint, Authentication, Fieldfromlightapi.authenticationimportJWTAuthentication, IsAuthenticated, IsAdminUseros.environ["LIGHTAPI_JWT_SECRET"] ="your-secret-key"classProtectedEndpoint(RestEndpoint):
secret: str=Field(min_length=1)
classMeta:
authentication=Authentication(backend=JWTAuthentication)
classAdminOnlyEndpoint(RestEndpoint):
data: str=Field(min_length=1)
classMeta:
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
Custom authentication: Subclass JWTAuthentication or BasicAuthentication and override validate_credentials():
fromlightapi.authenticationimportJWTAuthenticationclassMyAuthBackend(JWTAuthentication):
asyncdefvalidate_credentials(self, username: str, password: str) ->dict|None:
# Custom validation logic - query your database, check LDAP, etc.user=awaitself.get_user_from_db(username)
ifuserandawaituser.verify_password(password):
return {"sub": str(user.id), "is_admin": user.is_admin}
returnNoneclassProtectedEndpoint(RestEndpoint):
secret: str=Field(min_length=1)
classMeta:
authentication=Authentication(backend=MyAuthBackend)Login and token endpoints: When using JWTAuthentication or BasicAuthentication, pass login_validator to obtain automatic /auth/login and /auth/token endpoints (backward compatible):
defmy_validator(username: str, password: str):
# Return user payload dict or Noneuser=db.query(User).filter_by(username=username).first()
ifuseranduser.check_password(password):
return {"sub": str(user.id), "is_admin": user.is_admin}
returnNoneapp=LightApi(engine=engine, login_validator=my_validator)
app.register({"/secrets": ProtectedEndpoint})
# POST /auth/login and POST /auth/token now accept {"username":"...","password":"..."}# JWT mode: 200 {"token":"...","user":{...}}; Basic-only: 200 {"user":{...}}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=...):
fromlightapiimportRestEndpoint, Authenticationfromlightapi.authenticationimportJWTAuthenticationfromlightapi.rate_limiterimportRateLimiter# Using a RateLimiter instanceapp=LightApi(
engine=engine,
rate_limiter=RateLimiter(requests_per_minute=5, requests_per_hour=100, requests_per_day=1000),
)
# Or pass a dict with the same keysapp=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/loginendpoint, not to application endpoints.
Built-in permission classes:
| Class | Condition |
|---|---|
AllowAny | Always allowed (default) |
IsAuthenticated | request.state.user is not None |
IsAdminUser | request.state.user["is_admin"] == True |
Declare filter backends and allowed fields in Meta.filtering:
fromlightapiimportRestEndpoint, Filtering, Fieldfromlightapi.filtersimportFieldFilter, SearchFilter, OrderingFilterclassArticleEndpoint(RestEndpoint):
title: str=Field(min_length=1)
category: str=Field(min_length=1)
author: str=Field(min_length=1)
classMeta:
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 # case-insensitive LIKESearch is literal:
%and_in the search term are treated as plain characters, not SQL wildcards. A search forhello_worldmatches only rows containing the literal stringhello_world, nothelloXworld.
# Ordering (prefix - for descending)
GET /articles?ordering=-titleWhitelist required: When
orderingis not set (or empty), theOrderingFilterbackend ignores all?ordering=parameters. Only fields explicitly listed inorderingcan be sorted.
# Combine all
GET /articles?category=news&search=python&ordering=-titlefromlightapiimportRestEndpoint, Pagination, FieldclassPostEndpoint(RestEndpoint):
title: str=Field(min_length=1)
body: str=Field(min_length=1)
classMeta:
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": [...]}Override the base queryset by defining a queryset method:
fromsqlalchemyimportselectfromstarlette.requestsimportRequestfromlightapiimportRestEndpoint, FieldclassPublishedArticleEndpoint(RestEndpoint):
title: str=Field(min_length=1)
published: bool=Field()
defqueryset(self, request: Request):
cls=type(self)
returnselect(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.
Cache GET responses in Redis by setting Meta.cache:
fromlightapiimportRestEndpoint, Cache, FieldclassProductEndpoint(RestEndpoint):
name: str=Field(min_length=1)
price: float=Field(ge=0)
classMeta:
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"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.
fromstarlette.requestsimportRequestfromstarlette.responsesimportJSONResponse, ResponsefromlightapiimportLightApi, RestEndpoint, Fieldfromlightapi.coreimportMiddlewareclassRateLimitMiddleware(Middleware):
defprocess(self, request: Request, response: Response|None) ->Response|None:
ifresponseisNone: # pre-processingifrequest.headers.get("X-Rate-Limit-Exceeded"):
returnJSONResponse({"detail": "rate limit exceeded"}, status_code=429)
returnresponse# post-processing: passthroughclassMyEndpoint(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).
Map an existing database table without declaring columns:
classLegacyUserEndpoint(RestEndpoint):
classMeta:
reflect=Truetable="legacy_users"# existing table name in the databaseExtend an existing table with additional columns:
classExtendedEndpoint(RestEndpoint):
new_field: str=Field(min_length=1)
classMeta:
reflect="partial"table="existing_table"# reflect + add new_field columnConfigurationError is raised at app.register() time if the table does not exist.
Boot LightApi from a YAML file using LightApi.from_config(). Two formats are
supported — pick whichever fits your project.
Define endpoints, fields, and all Meta options directly in YAML. No Python
RestEndpoint classes required.
# lightapi.yamldatabase:
url: "${DATABASE_URL}"# ${VAR} env-var substitutioncors_origins:
- "https://myapp.com"mode: sync # or "async" for an async engine — auto-detected when omitted# Global defaults applied to every endpoint unless overriddendefaults:
authentication:
backend: JWTAuthenticationpermission: IsAuthenticatedpagination:
style: page_numberpage_size: 20middleware:
- CORSMiddlewareendpoints:
- route: /productsfields:
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: /ordersfields:
reference: { type: str }total: { type: float }meta:
methods: [GET, POST]# Override the global default for this endpoint onlyauthentication:
permission: AllowAny
- route: /productsfields:
name: { type: str, min_length: 1 }price: { type: float, ge: 0 }meta:
methods: [GET, POST, PUT, PATCH, DELETE]authentication:
permission: AllowAnycache:
ttl: 60# cache GET responses for 60 s; invalidated on writesserializer:
read: [id, name, version] # GET hides pricewrite: [id, name, price, version] # POST/PUT shows pricefromlightapiimportLightApiapp=LightApi.from_config("lightapi.yaml")
app.run()| Field | Type | Description |
|---|---|---|
database.url | string | SQLAlchemy URL. Supports ${VAR} env substitution. |
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. |
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. |
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
ConfigurationError with a precise message pointing to the offending field.
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.
uv add "lightapi[async]"# adds sqlalchemy[asyncio], asyncpg, aiosqlite, greenlet# sync — existing code, no changes requiredfromsqlalchemyimportcreate_engineengine=create_engine("postgresql://user:pass@localhost/db")
# async — one-line swapfromsqlalchemy.ext.asyncioimportcreate_async_engineengine=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
Define async def queryset to scope the base query asynchronously:
fromsqlalchemyimportselectfromstarlette.requestsimportRequestfromlightapiimportRestEndpoint, FieldclassOrderEndpoint(RestEndpoint):
amount: float=Field(ge=0)
status: str=Field(default="pending")
asyncdefqueryset(self, request: Request):
# e.g. scope to authenticated useruser_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.
Override individual HTTP verbs with async def. Mode is auto-detected — no explicit mode="async" needed:
classProductEndpoint(RestEndpoint):
name: str=Field(min_length=1)
price: float=Field(ge=0)
asyncdefpost(self, request: Request):
importjsondata=json.loads(awaitrequest.body())
# custom pre-processing ...returnawaitself._create_async(data)
asyncdefget(self, request: Request):
# custom query, external call, etc.returnawaitself._list_async(request)Auto-detect mode: LightAPI automatically detects whether an endpoint method is sync or async by checking if it's a coroutine function. Simply define async def get() and the framework will use async execution.
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 |
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):
asyncdefnotify(order_id: int) ->None:
# send email, write audit log, push notification …
...
classOrderEndpoint(RestEndpoint):
amount: float=Field(ge=0)
asyncdefpost(self, request: Request):
importjsonresp=awaitself._create_async(json.loads(awaitrequest.body()))
ifresp.status_code==201:
importjsonas_jsonself.background(notify, _json.loads(resp.body)["id"])
returnrespBoth def (sync) and async def callables are accepted by Starlette's BackgroundTasks. Calling self.background() outside a request handler raises RuntimeError.
Middleware.process can be a coroutine — LightAPI awaits it automatically. Sync and async middleware coexist in the same list:
fromlightapi.coreimportMiddlewarefromstarlette.requestsimportRequestfromstarlette.responsesimportResponseclassAsyncAuditMiddleware(Middleware):
asyncdefprocess(self, request: Request, response: Response|None) ->None:
ifresponseisNone:
awaitwrite_audit_log(request) # async I/OreturnNoneclassSyncHeaderMiddleware(Middleware):
defprocess(self, request: Request, response: Response|None) ->None:
ifresponseisnotNone:
response.headers["X-Served-By"] ="lightapi"returnNoneapp=LightApi(engine=engine, middlewares=[AsyncAuditMiddleware, SyncHeaderMiddleware])Pre-processing order: AsyncAuditMiddleware → SyncHeaderMiddleware.
Post-processing order (reversed): SyncHeaderMiddleware → AsyncAuditMiddleware.
Endpoints that still define sync methods work without modification on an async-engine app:
classTagEndpoint(RestEndpoint):
label: str=Field(min_length=1)
defqueryset(self, request: Request): # sync — still worksreturnselect(type(self)._model_class)LightAPI detects whether queryset / the override method is async and dispatches accordingly. No runtime penalty on the sync path.
get_sync_session and get_async_session are exported from lightapi for use in custom code:
fromlightapiimportget_sync_session, get_async_session# Syncwithget_sync_session(engine) assession:
rows=session.execute(select(MyModel)).scalars().all()
# Asyncasyncwithget_async_session(async_engine) assession:
rows= (awaitsession.execute(select(MyModel))).scalars().all()Both context managers commit on clean exit and roll back on exception.
Use pytest-asyncio and httpx.AsyncClient with an in-memory aiosqlite engine:
importpytestimportpytest_asynciofromhttpximportASGITransport, AsyncClientfromsqlalchemy.ext.asyncioimportcreate_async_enginefromlightapiimportLightApi, RestEndpointfromlightapi.authimportAllowAnyfromlightapi.configimportAuthenticationfrompydanticimportField@pytest_asyncio.fixtureasyncdefclient():
engine=create_async_engine("sqlite+aiosqlite:///:memory:")
classWidget(RestEndpoint):
name: str=Field(min_length=1)
classMeta:
authentication=Authentication(permission=AllowAny)
app=LightApi(engine=engine)
app.register({"/widgets": Widget})
asyncwithAsyncClient(
transport=ASGITransport(app=app.build_app()), base_url="http://test"
) asc:
yieldcasyncdeftest_create_widget(client):
r=awaitclient.post("/widgets", json={"name": "bolt"})
assertr.status_code==201assertr.json()["name"] =="bolt"Add to pytest.ini:
[pytest]asyncio_mode = autoLightApi(
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 omittedcors_origins: list[str] |None=None, # Allowed CORS originsmiddlewares: list[type] |None=None, # Middleware subclasses to mountauth_path: str="/auth", # Prefix for auth endpoints (default "/auth")session_manager: SessionManager|None=None, # Custom session/transaction managerrate_limiter: "RateLimiter | dict[str, int] | None"=None, # Login rate limiterlogin_validator: Callable[[str, str], dict[str, Any] |None] |None=None,
use_test_isolation: bool=False, # Enable per-test DB isolation
)| 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. |
| 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) — can return dict |
post | (request) | Override POST — can return dict |
put | (request) | Override PUT — can return dict |
patch | (request) | Override PATCH — can return dict |
delete | (request) | Override DELETE |
Return dict or Response: Endpoint override methods can return either a dict (auto-wrapped to JSONResponse) or a Starlette Response object:
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 |
classMyEndpoint(RestEndpoint):
classMeta:
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| 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"). |
| 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"} |
# 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-missingAsync test setup — add to pytest.ini:
[pytest]asyncio_mode = autoFor sync SQLite in-memory databases in tests, use StaticPool to share a single connection:
fromsqlalchemyimportcreate_enginefromsqlalchemy.poolimportStaticPoolfromstarlette.testclientimportTestClientfromlightapiimportLightApi, RestEndpoint, FieldclassItemEndpoint(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())| Variable | Default | Description |
|---|---|---|
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. |
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.ymlservices:
api:
build: .ports: ["8000:8000"]environment:
LIGHTAPI_DATABASE_URL: postgresql://postgres:pass@db:5432/mydbLIGHTAPI_JWT_SECRET: change-me-in-productionLIGHTAPI_REDIS_URL: redis://redis:6379/0depends_on: [db, redis]db:
image: postgres:16-alpineenvironment: {POSTGRES_DB: mydb, POSTGRES_USER: postgres, POSTGRES_PASSWORD: pass}redis:
image: redis:7-alpineInstalling 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.
export LIGHTAPI_CONFIG=./lightapi.yaml
export DATABASE_URL=postgresql://postgres:pass@localhost:5432/app
lightapi serveEvery 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.
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.
helm install shop charts/lightapi -f my-values.yaml# my-values.yaml: the entire developer-facing surfacedatabase:
url: "postgresql://postgres:pass@my-postgres:5432/app"# or database.existingSecretconfig:
database:
url: "${DATABASE_URL}"# resolved from the Secret at runtimeendpoints:
- route: /productsfields:
name: { type: str, max_length: 200 }price: { type: float }meta:
methods: [GET, POST, PUT, DELETE]authentication: { permission: AllowAny }See docs/deployment/helm.md and charts/lightapi/README.md for the full values reference and a minikube walkthrough.
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.
LightAPI is released under the MIT License. See LICENSE for details.
- 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