Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
150 commits
Select commit Hold shift + click to select a range
16e924c
chore: remove ten modules that nothing imports
Sep 18, 2026
d546aaf
chore: remove the unwired swagger module
Sep 18, 2026
5d9307b
docs: drop the stale FROZEN list from .cursorrules
Sep 18, 2026
6176df9
chore: remove the dead handler copies from lightapi.py
Sep 18, 2026
acf3161
chore: remove internals that have no caller
Sep 18, 2026
b8a3185
chore: remove unused constants and leftover no-ops
Sep 18, 2026
6c180e8
chore: drop lint and type-check entries for files that are gone
Sep 18, 2026
5f4969c
refactor: one CRUD implementation for sync and async engines
Sep 18, 2026
432ec49
fix: run sync CRUD off the event loop thread
Sep 18, 2026
49e2a42
fix: honour Meta.cache on async engines
Sep 18, 2026
c6dcb18
refactor: one EndpointHandler instead of two handler closures
Sep 18, 2026
19ba95b
fix: serve sync verb overrides
Sep 18, 2026
9f62208
fix: build auth backends from the endpoint config, login included
Sep 18, 2026
b220571
refactor: paginators share one render() and config builds them
Sep 18, 2026
0cbcd72
refactor: table sources replace five class flags and five mapper classes
Sep 18, 2026
cd2cc9a
refactor: take reflected column types from SQLAlchemy's python_type
Sep 18, 2026
b842acc
refactor: split the 150-line metaclass body into small functions
Sep 18, 2026
80fa532
refactor: break up LightApi.register, run and the constructor
Sep 18, 2026
a7be488
refactor: move row persistence out of RestEndpoint into Repository
Sep 18, 2026
831330d
refactor: split the YAML loader by job and merge auth settings once
Sep 18, 2026
b583efc
refactor: build the create/read schema pair in one place
Sep 18, 2026
2009d17
fix: build the Redis client on first use, not at import
Sep 18, 2026
b6e495e
feat: deprecate LightApi.from_dict ahead of its removal in 0.2.0
Sep 18, 2026
827176a
test: pin the documented public API against the published package
Sep 18, 2026
8642855
test: add a differential harness against the published package
Sep 18, 2026
67b8d78
fix: keep reflected float and uuid columns validating as before
Sep 18, 2026
2f07fca
Merge branch 'refactor/polymorphism' into refactor/single-responsibility
Sep 18, 2026
fa629b9
Merge branch 'refactor/single-responsibility' into refactor/no-global…
Sep 18, 2026
2468229
Merge branch 'refactor/no-global-state' into test/release-validation
Sep 18, 2026
69084db
test: make a stale install visible in the differential harness
Sep 18, 2026
3bd4d8d
test: add Level 3 integration tests against real Postgres and Redis
Sep 18, 2026
0c7ab42
test: add Level 4 tests (CLI, debug mode, CORS, rate limit, real server)
Sep 18, 2026
3c8cd42
test: close out the partial Level 7 cases (T7-03, T7-04, T7-07)
Sep 18, 2026
d12eab0
test: run the README Quick Start example as a real subprocess (T6-01)
Sep 18, 2026
c26362b
test: extract and run doc snippets from the pages touched by the refa…
Sep 18, 2026
2124e4d
refactor: group loose cache functions and response keys into class/enum
Sep 19, 2026
2230bbd
refactor: move filter constants and helpers onto BaseFilter
Sep 19, 2026
c818ede
refactor: encapsulate session_manager's global SQLAlchemy state
Sep 19, 2026
208feaf
fix: a pk too large for a 64-bit column 404s instead of 500
Sep 19, 2026
b7ca837
fix: reject or coerce a malformed PATCH/PUT version instead of 500
Sep 19, 2026
45797f5
fix: a non-numeric ?page= falls back to page 1 instead of 500
Sep 19, 2026
1eff6d9
Merge remote-tracking branch 'origin/refactor/group-loose-symbols' in…
Oct 11, 2026
d24c7f3
Merge remote-tracking branch 'origin/fix/edge-case-500s' into refacto…
Oct 11, 2026
e446b33
test: pin the behaviour the second OO pass will touch
Oct 11, 2026
e63b0bf
refactor: drop the unread _meta["reflect"] key and hoist rest.py's in…
Oct 11, 2026
fd6a4d3
refactor: use http.HTTPStatus instead of the copy in constants.py
Oct 11, 2026
5be7fba
refactor: name HTTP verbs with an HttpVerb enum
Oct 11, 2026
57fb00e
refactor: an ExecutionMode enum replaces the "sync"/"async" strings
Oct 11, 2026
db56448
refactor: PaginationStyle enum; the paginator is built once per Pagin…
Oct 11, 2026
6ccf08f
docs: class diagrams of lightapi before the second OO pass
Oct 11, 2026
1203ce7
refactor: TableReflection enum for Meta.reflect; unknown values now f…
Oct 11, 2026
d0f0f63
refactor: RateLimitWindow enum replaces the minute/hour/day if-chains
Oct 11, 2026
d045f12
refactor: JwtAlgorithm enum replaces the VALID_JWT_ALGORITHMS set
Oct 11, 2026
7efa0e4
refactor: ApiError enum owns the fixed error replies
Oct 11, 2026
48cf923
refactor: permissions say whether login is needed; backend built once…
Oct 11, 2026
54e0937
refactor: one module serves a route; argument adapters become an enum
Oct 11, 2026
6c13546
refactor: EndpointCache per route; Redis errors caught by their type
Oct 11, 2026
95ba476
refactor: Cursor value object; pagination and filters catch their own…
Oct 11, 2026
2d741f2
refactor: SessionManager owns run_blocking and sync_engine; narrow th…
Oct 11, 2026
dd3a7b0
refactor: FieldProjection and SchemaFactory own the row serialization…
Oct 11, 2026
6e7a9de
refactor: DeclaredTable builds its own columns; the metaclass keeps i…
Oct 11, 2026
80f8077
refactor: config loaders become methods of their models; LightApi own…
Oct 11, 2026
6a4764f
fix: an AsyncEngine on a sync app skips table creation with a clear w…
Oct 11, 2026
804d716
docs: diagrams of lightapi after the second OO pass and the pass's me…
Oct 11, 2026
bc8a203
docs(audit): baseline and inventory of the OO refactor audit
Oct 11, 2026
bfe14c4
fix(A4-001): drop RedisCacheBackend.delete, which nothing calls
Oct 11, 2026
57dbb6d
fix(A4-002): drop the unreachable LIGHTAPI_JWT_ALGORITHM fallback
Oct 11, 2026
8b07b00
fix(A4-003): one getattr finds the queryset hook on the class or its …
Oct 11, 2026
4171583
fix(B1-005,B1-018): log every Redis fallback with its traceback
Oct 11, 2026
f08edc2
fix(B1-004,B14-003): the login validator's exception is logged with i…
Oct 11, 2026
d21960f
fix(B1-010,B14-002): a failed CREATE TABLE at build_app is logged wit…
Oct 11, 2026
5e91407
fix(B2-001,B2-002,B2-003): read _meta and _session_manager as the att…
Oct 11, 2026
139303e
fix(B9-002): spell out the verb-to-mixin map instead of getattr by name
Oct 11, 2026
8d19606
fix(B5-003): thread-local state is built by its own __init__
Oct 11, 2026
5148ac8
fix(B5-002): decide once per middleware class whether process() is aw…
Oct 11, 2026
07b83af
fix(B8-001,B4-004): Pagination validates its style without rewriting …
Oct 11, 2026
d232ab8
fix(A2-001): Response's default status is HTTPStatus.OK, not a literal
Oct 11, 2026
1f7edd4
fix(B5-001,B9-003): resolve each verb's override and CRUD function at…
Oct 11, 2026
3d43b7b
docs(B13-001): say why nullable_columns is not cached per class
Oct 11, 2026
639dd6d
fix(B4-001): drop the type-ignores mypy reports as unused
Oct 11, 2026
b6cfdff
fix(B4-002,B4-003,B4-005,B4-007): config dataclasses declare what the…
Oct 11, 2026
024d1c1
fix(B4-008): Response.headers is dict[str, str] | None
Oct 11, 2026
f8bf3c4
fix(B4-012,B11-003): pass the methods dict instead of asserting its type
Oct 11, 2026
179c67f
fix(B4-011): the metaclass fills in attributes RestEndpoint declares
Oct 11, 2026
be91dd1
fix(B4-006): LightApi keeps type[RestEndpoint] in its endpoint map
Oct 11, 2026
97a8e0b
fix(B4-010): type-check every module but lightapi.cache
Oct 11, 2026
868034d
fix(B12-001): give the Redis client a read timeout, not only a connec…
Oct 11, 2026
ca70112
fix(B10-001): column defaults use the same naive UTC the Repository s…
Oct 11, 2026
daf38fb
fix(B14-005): declared columns go straight into their Table
Oct 11, 2026
3988875
fix(B10-003): open the YAML document through pathlib
Oct 11, 2026
841ff4a
fix(B15-005): internal boolean arguments are keyword-only
Oct 11, 2026
adfcd85
fix(B14-001): registration log lines are built by logging, not f-strings
Oct 11, 2026
63bcd5c
fix(B14-004): async apps create their tables in a lifespan, not on_st…
Oct 11, 2026
56a7ebd
fix(B14-007): one mapped class per name in each test module
Oct 11, 2026
c5a387f
fix(B16-001): drop the xfail markers on two tests that pass
Oct 11, 2026
d1ee2a8
fix(B14-006): pytest reads one config, with strict xfail and warnings…
Oct 11, 2026
a7f3086
fix(B16-002): the vary_on key test asserts the two cache writes it ex…
Oct 11, 2026
efd6160
fix(B15-003,B15-004): no commented-out-looking text, no stale noqa
Oct 11, 2026
c3465a4
fix(B4-009,B15-001): real types where the type is known; reasons on t…
Oct 11, 2026
83d12a2
chore(ruff): the final rule set, with tests and examples conforming
Oct 11, 2026
2cd35e1
docs(audit): phase 3 verification, a status and commit for every item
Oct 11, 2026
ffb9225
test(e2e): curl harness and the CRUD scenario on SQLite and Postgres
Oct 11, 2026
4030449
fix(B7-003): the lifespan creates tables on the loop that serves the app
Oct 11, 2026
949c228
test(e2e): the CRUD scenario on an async engine, aiosqlite and asyncpg
Oct 11, 2026
43826b8
fix(A10-002): a disallowed method gets the documented JSON 405
Oct 11, 2026
618c763
test(e2e): HttpMethod mixins answer 405 with Allow; Serializer forms …
Oct 11, 2026
9f87562
docs: the 403 body is "Insufficient permissions"
Oct 11, 2026
045f74a
fix(A10-003): a protected endpoint's 401 is the backend's own reply
Oct 11, 2026
52c466d
test(e2e): JWT login, token claims and expiry, permissions per method
Oct 11, 2026
f3af733
test(e2e): Basic authentication through a validate_credentials subclass
Oct 11, 2026
f589312
fix(B1-008): a filter value the column cannot parse answers 422
Oct 11, 2026
739f58f
test(e2e): field, search and ordering filters combined with page pagi…
Oct 11, 2026
7235f16
test(e2e): page-number links and a full cursor walk over 25 rows
Oct 11, 2026
8ccad27
test(e2e): static, sync and async queryset hooks filter the list route
Oct 11, 2026
53940fb
test(e2e): middleware order, short-circuit and CORS on an async app
Oct 11, 2026
2f66d41
test(e2e): background tasks run after the response, sync and async
Oct 11, 2026
6732872
fix(B2-011): reflected schemas honour nullable columns and declared F…
Oct 11, 2026
7229bae
test(e2e): full and partial reflection on SQLite and Postgres; a miss…
Oct 11, 2026
26a69e8
fix(B6-001): mode="async" builds an AsyncEngine from a database URL
Oct 11, 2026
fb11689
fix(B2-012): unknown YAML keys fail at load time
Oct 11, 2026
8961a0a
test(e2e): lightapi serve on sync and async YAML documents, defaults,…
Oct 11, 2026
6b5b784
test(e2e): the fourth login in a minute is 429 with the rate-limit he…
Oct 11, 2026
b16b04e
test(e2e): Meta.cache without Redis serves from the database and logs…
Oct 11, 2026
ffc8805
test(e2e): ten parallel writers on Postgres, one 200 and nine 409
Oct 11, 2026
b11e4ed
fix(B1-019): a constraint violation answers 409 instead of an unhandl…
Oct 11, 2026
48aa94d
fix(B6-006): Field(decimal_places=N) rejects a Decimal with more places
Oct 11, 2026
ab4d72a
test(e2e): Field column options reach the schema; a duplicate unique …
Oct 11, 2026
a3c2695
fix(B14-009): the container HEALTHCHECK probes /healthz, not /
Oct 11, 2026
c37b9c9
fix(B14-009): the HEALTHCHECK probe uses a raw socket, not urllib.req…
Oct 11, 2026
f7fe027
test(e2e): the container image serves the mounted YAML and reports he…
Oct 11, 2026
1e9a9d5
test(e2e): the scripts request the async extra; run_all checks the Po…
Oct 11, 2026
69087c6
docs(e2e): record the full run and what it forced
Oct 11, 2026
25d7643
fix(A10-002): routes list only the verbs they serve; the app renders …
Oct 11, 2026
d373528
fix(B1-019): a DELETE refused by a foreign key is 409 too, and every …
Oct 11, 2026
413a697
fix(B2-013): a PATCH meets the same field constraints as a PUT
Oct 11, 2026
ef2bea2
fix(B6-007): decimal_places on a non-Decimal field is refused at clas…
Oct 11, 2026
0eb581f
fix(B1-020): error messages never echo the database password
Oct 11, 2026
797c68a
fix(B2-014): a typo under fields.<name> is refused, not dropped
Oct 11, 2026
2a68f7b
test(e2e): steps for the review fixes; the image is built outside the…
Oct 11, 2026
c3e4f09
docs(e2e): record the review fix pass and the final runs
Oct 11, 2026
835904a
fix(B6-004): async mode is detected from the engine or the URL
Oct 11, 2026
e078cdb
fix(B6-005): an async validate_credentials on a Basic backend is refu…
Oct 11, 2026
f960717
fix(B1-021): an integer filter value beyond 64 bits is a 422
Oct 11, 2026
d9bbd33
fix(B7-004): async reflection leaves no connection bound to a dead loop
Oct 11, 2026
6df52e7
test(e2e): a PUT with version as a string; the reflected PATCH names …
Oct 11, 2026
087f523
docs: Basic 401 body, decimal_places wording, ApiError members, audit…
Oct 11, 2026
8b9ad46
docs(audit): commit hashes after the history rewrite
Oct 12, 2026
8aa2219
Merge pull request #45 from iklobato/refactor/group-loose-symbols
iklobato Oct 12, 2026
1aafaed
Merge pull request #49 from iklobato/refactor/oo-second-pass
iklobato Oct 12, 2026
f9b7cf1
Merge pull request #51 from iklobato/test/e2e-curl
iklobato Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 28 additions & 21 deletions .cursorrules
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,47 @@ Pydantic schema. No models.py, schemas.py, Column(), or @validates in user code.
- Pydantic v2 — Field(), create_model(), model_validate(), model_dump()
- Starlette — Request, JSONResponse, Response, Router, Route
- Uvicorn — ASGI server
- PyJWT — JWT auth (JWTAuthentication is FROZEN — do not modify)
- Redis — caching via frozen RedisCache/cache_manager
- PyJWT: JWT auth
- Redis: caching via RedisCache/cache_manager
- PyYAML — YAML config loading
- pytest + httpx + SQLite — testing only

## Module Map
| File | Role |
|------|------|
| lightapi/exceptions.py | ConfigurationError, SerializationError |
| lightapi/methods.py | HttpMethod.GET/POST/PUT/PATCH/DELETE markers |
| lightapi/config.py | Authentication, Filtering, Pagination, Serializer, Cache |
| lightapi/__init__.py | Public API re-exports (`__all__`) |
| lightapi/lightapi.py | LightApi: register(), build_app(), run(), from_config(), from_dict() |
| lightapi/rest.py | RestEndpointMeta, RestEndpoint auto-CRUD (sync and async share one core) |
| lightapi/endpoint_handler.py | EndpointHandler (one per route), EndpointGuard, MiddlewareChain, verb tables |
| lightapi/repository.py | Repository: row persistence, timestamps, optimistic locking |
| lightapi/schema.py | SchemaFactory (pydantic schemas, row_to_dict, patch_schema), FieldProjection |
| lightapi/table_mapping.py | TableSource: DeclaredTable (columns from fields), ReflectedTable |
| lightapi/session_manager.py | SessionManager (engine, metadata, registry, run_blocking) |
| lightapi/session.py | get_sync_session, get_async_session (public) |
| lightapi/config.py | Config (JWT settings), Authentication, Filtering, Pagination, Cache, Serializer |
| lightapi/constants.py | Enums: HttpVerb, ExecutionMode, TableReflection, JwtAlgorithm, ApiError, ResponseKey |
| lightapi/methods.py | HttpMethod.GET/POST/PUT/PATCH/DELETE marker mixins (public) |
| lightapi/fields.py | lightapi.Field() wrapper (strips foreign_key/unique/index/exclude) |
| lightapi/schema.py | SchemaFactory, _row_to_dict, _apply_fields, resolve_fields |
| lightapi/rest.py | RestEndpointMeta, RestEndpoint auto-CRUD |
| lightapi/auth.py | AllowAny, IsAuthenticated, IsAdminUser (JWTAuthentication FROZEN) |
| lightapi/filters.py | FilterBackend, SearchFilter, OrderingFilter |
| lightapi/pagination.py | PageNumberPaginator, CursorPaginator |
| lightapi/middleware.py | Middleware base |
| lightapi/__init__.py | LightApi, @app.route, @app.middleware, YAML, select shim |

## FROZEN — DO NOT MODIFY
- lightapi/auth.py: JWTAuthentication, BaseAuthentication (class bodies)
- lightapi/cache.py: RedisCache, BaseCache
- lightapi/swagger.py: SwaggerGenerator
- lightapi/lightapi.py: preserved as legacy until __init__.py rewrite absorbs it
- .github/workflows/
- pyproject.toml metadata fields (name, version, description, authors, etc.)
| lightapi/authentication/ | BaseAuthentication, JWTAuthentication, BasicAuthentication, permissions |
| lightapi/auth.py | Compatibility re-exports of lightapi.authentication |
| lightapi/_login.py | LoginRequest, LoginEndpoint (/auth/login, /auth/token) |
| lightapi/rate_limiter.py | RateLimiter, RateLimitWindow |
| lightapi/cache.py | RedisCacheBackend, EndpointCache (per route), legacy BaseCache/RedisCache |
| lightapi/filters.py | BaseFilter, FieldFilter, SearchFilter, OrderingFilter |
| lightapi/pagination.py | PaginationStyle, Cursor, NoPagination, PageNumberPaginator, CursorPaginator |
| lightapi/yaml_config.py | Pydantic models of lightapi.yaml and how they build the app (LightAPIConfig) |
| lightapi/_dict_config_loader.py | DictConfig behind the deprecated LightApi.from_dict |
| lightapi/core.py | Middleware, CORSMiddleware, AuthenticationMiddleware, Response (compat) |
| lightapi/health.py | /healthz |
| lightapi/server.py | `lightapi serve` command |
| lightapi/exceptions.py | ConfigurationError, SerializationError |

## Key Invariants (enforce in all generated code)
1. RestEndpoint IS the SQLAlchemy model AND the Pydantic schema.
2. Fields = annotated class attrs with Field(). Column() never appears in user code.
3. queryset is the only name for static and dynamic query definitions.
4. All validation via Pydantic Field(). @validates must not exist.
5. Every DB row passes through _row_to_dict → _apply_fields → model_validate → model_dump.
5. Every DB row passes through SchemaFactory.row_to_dict → FieldProjection.apply → model_validate → model_dump.
6. IsAdminUser checks payload["is_admin"] == True.
7. version (int, default 1) is auto-injected; PUT/PATCH require it; 409 on mismatch.
8. GET list always returns 200 with {"results": []} on empty; never 404.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ celerybeat.pid
*.sage.py
.env
.venv
.venv310/
env/
venv/
ENV/
Expand All @@ -108,3 +109,5 @@ cython_debug/
.idea
alembic
*.db

tools/e2e/.out/
178 changes: 178 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,184 @@ Versions align with [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [Unreleased]

### Deprecated
- **`LightApi.from_dict`** emits a `DeprecationWarning` and is removed in 0.2.0. Use
`RestEndpoint` subclasses with `register()`, or `from_config()` with a YAML file.

### Changed
- **The authentication backend is built once per route**, on the first request that needs
it, instead of once per request. A backend subclass with its own `__init__` is therefore
instantiated once. JWT settings and the secret are still read on every request.
- **A permission class decides whether login is required**: `BasePermission` has
`requires_authentication = True` and `AllowAny` sets it to `False`. The only visible
difference: a subclass of `AllowAny` used as a permission no longer forces authentication
(before, only `AllowAny` itself, by identity, made a method public).
- **`/auth/login` with a body that is not valid UTF-8 returns 422** (it was a 500); a body
that is not JSON still returns the same 422.
- **`Meta.reflect` rejects unknown values**: anything other than `True`, `False`, `"full"`
or `"partial"` now raises `ConfigurationError` when the class is defined. Before, a value
such as `"yes"` was silently treated as "not reflected" and produced a table with only the
automatic columns.
- **Sync CRUD runs in a worker thread**: with a sync `Engine`, `list`, `retrieve`,
`create`, `update`, `destroy` and the response-cache calls ran on the event loop
thread, so one slow query held up every other request on that worker. They now
go through `starlette.concurrency.run_in_threadpool`. Engines on `StaticPool` or
`SingletonThreadPool` (in-memory SQLite) still run in place, because those pools
cannot be shared across threads.

### Removed (internal, no public name affected)
- `RestEndpoint._meta["reflect"]`: the key was written but never read; `Meta.reflect` is
read from the class itself.
- `lightapi.constants.HTTPStatus`: a copy of eleven members of the standard library's
`http.HTTPStatus`, which the package now uses directly. Status codes in responses are
unchanged.
- `lightapi.auth_checker` and `lightapi.middleware_runner`: `EndpointGuard` and the
middleware runner (now `MiddlewareChain`) live in `lightapi.endpoint_handler`, the only
module that used them.
- `lightapi.cache_helper` and the module functions `lightapi.cache.get_cached`,
`set_cached` and `_ping_redis`: the response cache is `lightapi.cache.EndpointCache`
(built once per route) over `RedisCacheBackend.shared()`, the process-wide client that is
still created on first use. `lightapi.cache.invalidate_cache_prefix` stays.
- `lightapi.yaml_loader`, `lightapi.yaml_names` and `lightapi.yaml_schema`: the YAML
document's models and the code that turns them into endpoints now live together in
`lightapi.yaml_config` (`LightAPIConfig.from_file(...).build_app(...)`,
`LightAPIConfig.resolve_name`). The YAML format is unchanged.
- `lightapi._dict_config_loader.load_from_dict`: `LightApi.from_dict` (deprecated) now goes
through `DictConfig` in the same module.

### Fixed
- **`Meta.cache` was ignored on async engines**: cached GETs and cache invalidation
only ran on the sync path, so an app on an `AsyncEngine` never read or wrote the
cache. Both engine kinds now use it. The Redis calls run in a worker thread.
- **Sync verb overrides were ignored**: a plain `def get(self, request)` (or `post`,
`put`, `patch`, `delete`) on an endpoint was skipped and the built-in CRUD ran
instead; only `async def` overrides were served. Both are served now, on sync and
async engines. A column that happens to be called `post` or `get` is still a
column, not an override.
- **`/auth/login` ignored a backend's `validate_credentials`**: the login route
always validated through a bare `JWTAuthentication()`, so overriding
`validate_credentials()` on a subclass (the README's "custom authentication")
never ran and login returned `401`. The route now uses the backend the endpoint
configured, sync or `async def`. An app-level `login_validator` still wins.
- **Auth settings were dropped for backend subclasses**: `jwt_expiration`,
`jwt_algorithm` and `jwt_extra_claims` only reached a backend whose class was named
exactly `JWTAuthentication`. Subclasses get them too, unless they define their own
`__init__`.
- **`LIGHTAPI_REDIS_URL` was read at import time**: the Redis client behind
`Meta.cache` was built when `lightapi` was imported, so a URL set afterwards (in
code, or by a test) was ignored. It is built on the first cache call now.
- **Redis failures are logged**: a cache read, write, invalidation or ping that fails
still falls back to the database as before, but now leaves a warning with the
traceback (`lightapi.cache`) instead of nothing. A `login_validator` that raises still
answers `401`, and the exception is logged with its traceback (`lightapi._login`), as is
a failed `CREATE TABLE` at `build_app()`.
- **The Redis client has a read timeout**: `socket_timeout=1` next to the existing
`socket_connect_timeout=1`, so a Redis that accepts the connection and hangs costs one
second and a cache miss, not a stuck worker.
- **Verb overrides and CRUD functions are resolved once, at `register()`**: a `get`/`post`
method assigned to the endpoint class after `register()` is no longer picked up per
request. Middleware classes are checked for `async def process` once per class.
- **`Pagination(style=...)` validates without rewriting the frozen field**; an invalid style
raises `ConfigurationError` as before, and `Pagination.style` keeps the value as given
(`PaginationStyle` member or its string).
- **`Authentication().build_backend()` without a backend raises `ConfigurationError`**
instead of `AttributeError`; a CRUD call on an endpoint class that was never passed to
`register()` raises `RuntimeError` naming the missing call instead of SQLAlchemy's
`ArgumentError`.
- **Async apps create their tables in a Starlette lifespan** instead of the deprecated
`on_startup` handler; same moment, no deprecation warning. `created_at`/`updated_at`
column defaults no longer use the deprecated `datetime.utcnow()`; the stored value is the
same naive UTC.
- **The container HEALTHCHECK probes `/healthz` over a raw socket**: it hit `/`, which is a
404 on every LightAPI app, so `docker inspect` reported every healthy container as
unhealthy. The probe also stops importing `urllib.request`, which alone took 1.8 to 2.9 s
inside the image on Docker Desktop and blew the 3 s timeout; the socket probe takes 0.7 s.
- **PATCH validates every field constraint**: the PATCH schema rebuilt each field as an
optional copy and dropped its `Field()` constraints, so `min_length`, `ge`,
`decimal_places` and the rest applied to POST and PUT but not to PATCH (`{"name": ""}`
against `min_length=3` was a 200). A PATCH now meets the same constraints as a PUT and
answers 422 like it; only the "every field is optional" part is unchanged.
- **`decimal_places` on a non-Decimal field is refused when the class is defined**: a
`ConfigurationError` names the field. Before this release the option was silently
ignored on `float` or `int`; once forwarded to pydantic it would have made every write a
500 (`TypeError: Unable to apply constraint`). Same through YAML.
- **`Field(decimal_places=N)` is enforced**: a `Decimal` with more than N decimal places
is a 422. Before, the option reached neither pydantic nor the DDL (`Numeric(scale=N)`
without a precision renders as plain `NUMERIC` on every dialect), so "1.999" was stored
as sent on a `decimal_places=2` column. The column type is unchanged.
- **A database constraint violation is a 409**: a duplicate on a `unique=True` column, a
DELETE of a row another table still references, or any other `IntegrityError` on POST,
PUT, PATCH or DELETE answers `409 {"detail": "constraint violation"}` and leaves the
session usable. It was an unhandled 500. The server logs the table and the driver's
text at INFO, because a NOT NULL or CHECK failure usually means the schema and the
table disagree.
- **Error messages never echo the database password**: the `ConfigurationError` for a
URL without an async driver renders the URL with the password masked, and the YAML
load error lists each problem as location and message only, so a typo like `databse:`
no longer prints the block it rejected (which held the URL, or `password:`) into the
logs.
- **A YAML key the format does not know fails at load time**: `defaults.filtering`,
`meta.paginate` or a misspelt top-level key now raise `ConfigurationError` naming the
key, as the configuration guide promises. Before, pydantic dropped the key and the app
started without the feature. A key under `fields.<name>` that is not a constraint
(`min_lenght: 3`) is refused the same way; before, the constraint silently vanished.
- **Async mode is detected from the engine or the URL**: `LightApi(engine=AsyncEngine)`
and `LightApi(database_url="sqlite+aiosqlite://...")` (or a YAML document with such a
URL) run async without `mode="async"`, as the README always said. Before, the sync
default never created the tables and every request was a 500 (with a "Table creation
skipped" warning); the docs' own async testing fixture hit it verbatim. An explicit
`mode="sync"` with an async engine or driver is a `ConfigurationError`.
- **An `async def validate_credentials` on a Basic backend is refused at `register()`**:
the request guard calls it without `await`, and a coroutine is truthy, so every Basic
credential used to pass. The error names the backend; `JWTAuthentication` subclasses
may keep an async validator (only the login route calls it, and that one awaits).
- **An integer filter value beyond 64 bits is a 422** (`?pages=99999999999999999999`),
like any other value the column cannot hold. It was an `OverflowError` 500 on SQLite.
- **Async reflection no longer leaves a connection bound to a dead loop in the pool**:
`Meta.reflect` on an `AsyncEngine` reflects on a loop of its own at `register()` and now
disposes the pool afterwards (not for an in-memory SQLite `StaticPool`, which holds the
database), so asyncpg's first request no longer finds a connection from another loop.
- **`mode="async"` works from a database URL**: `LightApi(database_url=..., mode="async")`
and a YAML document with `mode: async` build an `AsyncEngine`, so `lightapi serve` can
start an async app. Before, both failed at `register()` with "requires AsyncEngine". A
URL without an async driver now raises `ConfigurationError` naming the drivers that work.
- **Reflected tables accept what the table and the endpoint allow to be absent**: a
nullable column of a reflected table is optional on POST (stored as null), and on
`reflect = "partial"` a declared `Field(default=...)` or `Field(min_length=...)` is
honoured. Before, every non-auto column was required on POST, and the declared
`Field()` was dropped once the class was mapped, so `notes: str = Field(default="")`
still answered 422 "Field required".
- **A filter value the column cannot parse is a 422**: `?pages=lots` on an integer
column answers `422 {"detail": [{"loc": ["query", "pages"], ...}]}`. Before, the raw
string reached the database: an empty list on SQLite, a 500 (`invalid input syntax for
type integer`) on Postgres. `lightapi.filters._coerce_filter_value` raises
`FilterValueError` for such a value; custom backends that call it get the same.
- **A protected endpoint's 401 is the backend's own reply**: the guard answered every
failed authentication with `{"detail": "Authentication required"}` and
`WWW-Authenticate: Bearer`, whatever the backend. It now returns the backend's
`get_auth_error_response(request)`, so a JWT endpoint answers the documented
`{"detail": "Authentication credentials invalid."}` with `Bearer`, a Basic endpoint
answers `{"error": "authentication failed"}` with `Basic realm="Restricted Area"` (the
challenge a browser needs to prompt), and a backend subclass that overrides the hook is
finally heard. Status 401 unchanged.
- **A method the endpoint does not allow answers the documented JSON 405**:
`{"detail": "Method Not Allowed. Allowed: GET"}` with the `Allow` header, on the CRUD
routes and on `/auth/login`. Before, Starlette answered with its `text/plain` "Method
Not Allowed" (the JSON reply existed in the code but only `HEAD` ever reached it). The
routes still list only the verbs they serve, so two classes may share a path split by
method, and a disallowed verb is refused before the authentication guard, the
middlewares and the login rate limiter see it. `/auth/login` answers the same body as
the CRUD routes; its former `{"detail": "method not allowed"}` is gone. Status and
`Allow` are unchanged.
- **An async app on asyncpg no longer fails its first request after startup** ("Future
attached to a different loop"). The startup created the tables on a worker thread with
its own event loop, and the connection that did it went back to the pool bound to that
loop. The lifespan now runs the DDL on the loop that serves the requests. `build_app()`
called from inside an already running loop (a pytest-asyncio test) still uses the worker
thread; create the tables yourself in that case.

## [Unreleased] — 0.1.24

### Fixed
Expand Down
13 changes: 8 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ USER lightapi

EXPOSE 8000

# Lightweight health check — touches the root path expecting any HTTP response.
# Health check against the liveness route the app always registers. A raw
# socket, not urllib: importing urllib.request alone took 1.8 to 2.9 s inside
# this image on Docker Desktop, which blew the 3 s timeout; the socket probe
# measures 0.7 s end to end there.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request, sys; \
import os; \
url='http://127.0.0.1:'+os.environ.get('LIGHTAPI_PORT','8000')+'/'; \
sys.exit(0 if urllib.request.urlopen(url, timeout=2).status else 1)" \
CMD python -c "import socket, os; \
s = socket.create_connection(('127.0.0.1', int(os.environ.get('LIGHTAPI_PORT', '8000'))), timeout=2); \
s.sendall(b'GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n'); \
raise SystemExit(0 if b' 200 ' in s.recv(64) else 1)" \
|| exit 1

ENTRYPOINT ["python", "/app/entrypoint.py"]
Loading