Skip to content

Repository files navigation

LightAPI v2: Annotation-Driven Python REST Framework

PyPI versionPython 3.10+License: MIT

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?

  • 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.
  • 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.
  • Optimistic locking built in: Every endpoint gets a version field. PUT/PATCH require version in the body; mismatches return 409 Conflict.
  • 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.
  • No aiohttp: Pure Starlette + Uvicorn ASGI stack, no async framework mixing.
  • Pydantic v2: Full model_validate, model_dump(mode='json'), ConfigDict compatibility.
  • SQLAlchemy 2.0 imperative mapping: No DeclarativeBase inheritance 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.

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:latest

See Docker deployment for the full guide.


Quick Start

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:

MethodURLDescription
GET/booksList all books ({"results": [...]})
POST/booksCreate 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():

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 annotationColumn typeNullable
strVARCHARNo
Optional[str]VARCHARYes
intINTEGERNo
Optional[int]INTEGERYes
floatFLOATNo
boolBOOLEANNo
datetimeDATETIMENo
DecimalNUMERIC(scale=N)No
UUIDUUIDNo

LightAPI-specific Field() kwargs (stored in json_schema_extra, not passed to Pydantic):

KwargEffect
foreign_key="table.col"Adds ForeignKey constraint on the column
unique=TrueAdds UNIQUE constraint
index=TrueAdds a database index
exclude=TrueColumn is skipped entirely (no DB column, no schema field)
decimal_places=NSets Numeric(scale=N) (used with Decimal type)

Auto-injected Columns

Every RestEndpoint subclass automatically gets these columns — you never declare them:

ColumnTypeDefault
idInteger PKautoincrement
created_atDateTimeutcnow on insert
updated_atDateTimeutcnow on insert + update
versionInteger1 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.

Clearing Optional fields: Send an explicit null value 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 null values — they are treated as if the field was not sent.

HttpMethod Mixins

Control which HTTP verbs your endpoint exposes by mixing in HttpMethod.* classes:

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.

Serializer

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# reused

Authentication and Permissions

Use 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:

  1. JWTAuthentication.authenticate(request) — extracts and validates Authorization: Bearer <token>, stores payload in request.state.user
  2. Permission class .has_permission(request) — checks request.state.user
  3. Returns 401 if authentication fails, 403 if 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/login endpoint, not to application endpoints.

Built-in permission classes:

ClassCondition
AllowAnyAlways allowed (default)
IsAuthenticatedrequest.state.user is not None
IsAdminUserrequest.state.user["is_admin"] == True

Filtering, Search, and Ordering

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 LIKE

Search is literal:% and _ in the search term are treated as plain characters, not SQL wildcards. A search for hello_world matches only rows containing the literal string hello_world, not helloXworld.

# Ordering (prefix - for descending)
GET /articles?ordering=-title

Whitelist required: When ordering is not set (or empty), the OrderingFilter backend ignores all ?ordering= parameters. Only fields explicitly listed in ordering can be sorted.

# Combine all
GET /articles?category=news&search=python&ordering=-title

Pagination

fromlightapiimportRestEndpoint, 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": [...]}

Custom Queryset

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.

Response Caching

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, DELETE automatically invalidate the cache for that endpoint's key prefix.
  • If Redis is unreachable at app.run(), a RuntimeWarning is 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 a Response to 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).

Database Reflection

Map an existing database table without declaring columns:

classLegacyUserEndpoint(RestEndpoint):
classMeta:
reflect=Truetable="legacy_users"# existing table name in the database

Extend 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 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.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 price
fromlightapiimportLightApiapp=LightApi.from_config("lightapi.yaml")
app.run()

YAML field reference

FieldTypeDescription
database.urlstringSQLAlchemy URL. Supports ${VAR} env substitution.
cors_originslistCORS allowed origins.
defaults.authenticationobjectbackend + permission applied to every endpoint.
defaults.paginationobjectstyle + page_size applied to every endpoint.
middlewarelistClass names or dotted paths resolved at startup.
endpoints[].routestringURL prefix.
endpoints[].fieldsobjectInline field definitions — type, constraints, optional.
endpoints[].meta.methodslist or dictHTTP methods to enable; dict form allows per-method auth.
endpoints[].meta.authenticationobjectOverrides defaults.authentication for this endpoint.
endpoints[].meta.filteringobjectfields, search, ordering lists.
endpoints[].meta.paginationobjectstyle + page_size for this endpoint.
mode"sync" or "async"Engine mode. Auto-detected when omitted.
endpoints[].meta.cacheobject{ ttl: N } — cache GET responses for N seconds (requires Redis).
endpoints[].meta.serializerobject{ fields: [...] } or { read: [...], write: [...] } — field projection.
endpoints[].meta.tablestringCustom table name (required when using reflect: true).
endpoints[].reflectboolReflect 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.


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 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 AsyncSession for every request
  • Awaits async def queryset, async def get/post/put/patch/delete overrides
  • Falls back to sync CRUD for endpoints that still define sync methods
  • Runs metadata.create_all inside the server's event loop via Starlette on_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:

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.

Async Method Overrides

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:

MethodDescription
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):

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"])
returnresp

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:

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.

Sync Endpoints on an Async App

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.

Session Helpers

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.

Testing Async Endpoints

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 = auto

API Reference

LightApi

LightApi(
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 / ParameterDescription
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
engineSQLAlchemy engine (sync or async). Takes priority over database_url.
database_urlDSN string — a sync engine is created automatically.
mode"sync" or "async". Auto-detected from the engine type when omitted.
cors_originsList of allowed CORS origins passed to CORSMiddleware.
middlewaresAdditional Starlette middleware classes to mount.
auth_pathURL prefix for the auto-generated auth routes (default "/auth").
session_managerSupply a custom SessionManager for transaction handling.
rate_limiterRateLimiter instance or dict — limits /auth/login requests only.
login_validatorCallable (username, password) → dict | None — validates login credentials.
use_test_isolationWrap each request in a savepoint for isolated unit tests.

RestEndpoint

AttributeTypeDescription
_metadictParsed Meta configuration
_allowed_methodsset[str]HTTP verbs this endpoint handles
_model_classtypeSQLAlchemy-mapped class (same as type(self))
__schema_create__ModelMetaclassPydantic model for POST/PUT/PATCH input
__schema_read__ModelMetaclassPydantic model for responses

Override these methods to customise behaviour. Both def (sync) and async def (async) variants are detected automatically:

MethodSignatureDefault 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):

HelperDescription
_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

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
AttributeTypeDescription
authenticationAuthenticationBackend and permission class for this endpoint.
filteringFilteringFilter backends, fields, search, and ordering lists.
paginationPaginationPagination style and page size.
serializerSerializerField projection for reads and/or writes.
cacheCacheCache(ttl=N) — cache GET responses for N seconds (requires Redis).
reflectbool | "partial"Reflect an existing table (True) or extend it ("partial").
tablestrOverride the inferred table name (default: f"{ClassName.lower()}s").

Error responses

ScenarioStatus codeBody
Validation failure422{"detail": [...pydantic errors...]}
Not found404{"detail": "not found"}
Optimistic lock conflict409{"detail": "version conflict"}
Auth failure401{"detail": "Authentication credentials invalid."}
Permission denied403{"detail": "You do not have permission to perform this action."}
Method not registered405{"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:

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())

Configuration

Environment variables

VariableDefaultDescription
LIGHTAPI_DATABASE_URLDatabase connection URL when no engine or database_url is passed. One of engine, database_url, or LIGHTAPI_DATABASE_URL is required.
LIGHTAPI_JWT_SECRETRequired for JWTAuthentication
LIGHTAPI_REDIS_URLredis://localhost:6379/0Redis URL for response caching
LIGHTAPI_CONFIGPath to the declarative YAML config. Read by the lightapi serve command and the container image.
LIGHTAPI_HOST0.0.0.0Bind host for lightapi serve.
LIGHTAPI_PORT8000Bind port for lightapi serve.

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.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-alpine

lightapi serve command

Installing LightAPI adds a lightapi console command. lightapi serve boots a server from the environment: it reads LIGHTAPI_CONFIG (the declarative YAML), LIGHTAPI_HOST, and LIGHTAPI_PORT, so a container needs no custom entry script. python -m lightapi does the same.

export LIGHTAPI_CONFIG=./lightapi.yaml
export DATABASE_URL=postgresql://postgres:pass@localhost:5432/app
lightapi serve

Health check

Every app registers GET /healthz, which returns 200 {"status": "ok"}. It is process-level (it does not check the database) and is meant for liveness and readiness probes.

Kubernetes (Helm)

The charts/lightapi chart deploys a declarative CRUD API over an existing database with only a values.yaml (no CRUD code and no per-project image build). The chart passes your declarative config to LightApi.from_config and runs it; adding a table means adding an entry under config.endpoints and running helm upgrade.

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.


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:

  1. Fork the repository and create a feature branch
  2. Write tests for new features — all existing tests must remain green
  3. Follow the existing code style (PEP 8, type hints everywhere)
  4. 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

About

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.

Topics

Resources

Stars

115 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages