diff --git a/.cursorrules b/.cursorrules index 9ceff6e..7fd222c 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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. diff --git a/.gitignore b/.gitignore index 80d0595..1a1bc19 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ celerybeat.pid *.sage.py .env .venv +.venv310/ env/ venv/ ENV/ @@ -108,3 +109,5 @@ cython_debug/ .idea alembic *.db + +tools/e2e/.out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e7fe9..07a4563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.` 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 diff --git a/Dockerfile b/Dockerfile index bd4bc5b..b23344d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index c1fb106..d9f1642 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ class ProductEndpoint(RestEndpoint): | `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) | +| `decimal_places=N` | A `Decimal` with more than N decimal places is rejected (422); the column type stays `NUMERIC` | ### Auto-injected Columns @@ -984,8 +984,10 @@ class MyEndpoint(RestEndpoint): | 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."}` | +| Constraint violation (unique, foreign key) | `409` | `{"detail": "constraint violation"}` | +| Auth failure (JWT) | `401` | `{"detail": "Authentication credentials invalid."}` with `WWW-Authenticate: Bearer` | +| Auth failure (Basic) | `401` | `{"error": "authentication failed"}` with `WWW-Authenticate: Basic realm="Restricted Area"` | +| Permission denied | `403` | `{"detail": "Insufficient permissions"}` | | Method not registered | `405` | `{"detail": "Method Not Allowed. Allowed: GET, POST"}` | --- diff --git a/docs/advanced/validation.md b/docs/advanced/validation.md index 0a3c2b9..e2094ca 100644 --- a/docs/advanced/validation.md +++ b/docs/advanced/validation.md @@ -57,7 +57,7 @@ When validation fails, LightAPI returns `422 Unprocessable Entity` with a detail | `int` | `Integer` | | | `float` | `Float` | | | `bool` | `Boolean` | | -| `Decimal` | `Numeric` | Use `decimal_places=N` extra kwarg | +| `Decimal` | `Numeric` | `decimal_places=N` caps the places a value may carry | | `datetime.datetime` | `DateTime` | | | `Optional[T]` | nullable column | Makes the column nullable | @@ -70,7 +70,7 @@ Beyond standard Pydantic constraints, LightAPI adds extra kwargs processed by th | `unique=True` | bool | Adds a `UNIQUE` constraint to the column. | | `index=True` | bool | Creates a database index on the column. | | `foreign_key="table.col"` | str | Creates a foreign key reference. | -| `decimal_places=N` | int | Precision for `Decimal` columns (default: 10). | +| `decimal_places=N` | int | A `Decimal` with more than N decimal places is rejected (422); the column type stays `NUMERIC`. | | `exclude=True` | bool | Skips column creation entirely — field exists only on the schema. | | `default=` | any | Sets both the Pydantic default and the SQLAlchemy column default. | diff --git a/docs/api-reference/core.md b/docs/api-reference/core.md index 00afd28..f5a1f44 100644 --- a/docs/api-reference/core.md +++ b/docs/api-reference/core.md @@ -86,6 +86,9 @@ a precise message before the server starts. ### `from_dict(config: dict) → LightApi` +!!! warning "Deprecated" + `from_dict` is removed in 0.2.0 and emits a `DeprecationWarning` today. Declare `RestEndpoint` subclasses and call `register()`, or use `from_config()` with a YAML file. + ```python app = LightApi.from_dict({ "database_url": "sqlite:///db.sqlite3", diff --git a/docs/api-reference/filters.md b/docs/api-reference/filters.md index 73a5b42..4caedac 100644 --- a/docs/api-reference/filters.md +++ b/docs/api-reference/filters.md @@ -53,7 +53,7 @@ Applies exact `WHERE col = value` for query parameters in `fields`. GET /articles?published=true&category=tech ``` -**Type coercion**: string query parameter values are automatically converted to the correct Python type (`bool`, `int`, `float`) based on the SQLAlchemy column type. This prevents type errors with strict databases like PostgreSQL. +**Type coercion**: string query parameter values are automatically converted to the correct Python type (`bool`, `int`, `float`) based on the SQLAlchemy column type. A value an `int` or `float` column cannot parse (`?pages=lots`) answers `422` with `{"detail": [{"loc": ["query", "pages"], ...}]}` instead of reaching the database. **Class:** `lightapi.filters.FieldFilter` diff --git a/docs/api-reference/models.md b/docs/api-reference/models.md index 563c6d6..c331d28 100644 --- a/docs/api-reference/models.md +++ b/docs/api-reference/models.md @@ -53,7 +53,7 @@ class ProductEndpoint(RestEndpoint): | `unique=True` | `bool` | Adds `UNIQUE` constraint to the column | | `index=True` | `bool` | Creates a database index | | `foreign_key="table.col"` | `str` | Creates a `ForeignKey` reference | -| `decimal_places=N` | `int` | Precision for `Decimal` columns (default: 10) | +| `decimal_places=N` | `int` | A `Decimal` with more than N decimal places is rejected (422); the column type stays `NUMERIC` | | `exclude=True` | `bool` | Skips column creation — field is schema-only | ## Auto-injected columns diff --git a/docs/api-reference/rest.md b/docs/api-reference/rest.md index 7ec2f9d..030d61c 100644 --- a/docs/api-reference/rest.md +++ b/docs/api-reference/rest.md @@ -53,7 +53,7 @@ class ProductEndpoint(RestEndpoint): | `unique=True` | Adds `UNIQUE` constraint | | `index=True` | Adds a database index | | `exclude=True` | Field is excluded from DB and schema entirely | -| `decimal_places=N` | Sets `Numeric(scale=N)` (for `Decimal` fields) | +| `decimal_places=N` | A `Decimal` with more than N decimal places is rejected (422); the column type stays `NUMERIC` | ### Auto-injected Columns @@ -218,8 +218,10 @@ Unregistered methods return `405 Method Not Allowed` with an `Allow` header list | 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."}` | +| Constraint violation (unique, foreign key) | `409` | `{"detail": "constraint violation"}` | +| Auth failure (JWT) | `401` | `{"detail": "Authentication credentials invalid."}` with `WWW-Authenticate: Bearer` | +| Auth failure (Basic) | `401` | `{"error": "authentication failed"}` with `WWW-Authenticate: Basic realm="Restricted Area"` | +| Permission denied | `403` | `{"detail": "Insufficient permissions"}` | | Method not registered | `405` | `{"detail": "Method Not Allowed. Allowed: GET, POST"}` | --- diff --git a/docs/audit.md b/docs/audit.md new file mode 100644 index 0000000..1eb4752 --- /dev/null +++ b/docs/audit.md @@ -0,0 +1,369 @@ +# Audit of the OO refactor (branch `audit/second-pass`) + +Single source of truth for the audit. Reference version: v0.1.29, commit `f6d727b` (the +last commit before any refactor; `d537385`, the branch base, differs from it by the version +bump only). Audited state: commit `804d716` (`refactor/oo-second-pass`, on top of PR #44, +#45 and #46). + +## Summary (updated at every commit) + +| Item | Occurrences | corrigido | justificado | proposta | aberto | +|---|---|---|---|---|---| +| P0 tests weakened | 0 | 0 | 0 | 0 | 0 | +| P0 HTTP differences | 0 | 0 | 0 | 0 | 0 | +| A1 loose functions | 2 | 0 | 2 | 0 | 0 | +| A2 closed-set literals | 1 | 1 | 0 | 0 | 0 | +| A3 own HTTPStatus | 0 | 0 | 0 | 0 | 0 | +| A4 dead code | 3 | 3 | 0 | 0 | 0 | +| A5 sync/async duplication | 2 | 0 | 2 | 0 | 0 | +| A6 stateless/utility classes | 3 | 0 | 3 | 0 | 0 | +| A7 duplicate flows | 1 | 0 | 1 | 0 | 0 | +| A8 backend construction | 1 | 0 | 1 | 0 | 0 | +| A9 per-request constants | 0 | 0 | 0 | 0 | 0 | +| A10 error replies | 3 | 2 | 1 | 0 | 0 | +| B1 silent errors | 21 | 8 | 11 | 2 | 0 | +| B2 hiding defaults | 14 | 7 | 7 | 0 | 0 | +| B3 sentinel returns | 3 | 0 | 3 | 0 | 0 | +| B4 lying types | 12 | 12 | 0 | 0 | 0 | +| B5 per-request checks | 9 | 3 | 6 | 0 | 0 | +| B6 silently disabled features | 7 | 5 | 0 | 2 | 0 | +| B7 blocking in async | 4 | 2 | 0 | 2 | 0 | +| B8 hidden mutation / globals | 5 | 1 | 4 | 0 | 0 | +| B9 dispatch by name | 5 | 3 | 2 | 0 | 0 | +| B10 time and formats | 3 | 2 | 1 | 0 | 0 | +| B11 weak security defaults | 6 | 1 | 3 | 2 | 0 | +| B12 resources and timeouts | 1 | 1 | 0 | 0 | 0 | +| B13 repeated work per request | 5 | 0 | 4 | 1 | 0 | +| B14 logs and warnings | 9 | 8 | 1 | 0 | 0 | +| B15 hidden YAGNI | 5 | 3 | 1 | 1 | 0 | +| B16 tests that do not protect | 5 | 2 | 3 | 0 | 0 | +| **Total** | **130** | **64** | **56** | **10** | **0** | + +## Phase 0: baseline + +Commands, run from each tree's root (`uv run --frozen --extra dev ...` for the current tree, +`uv run --directory ../lightapi-original --isolated --frozen --extra dev ...` for the +original worktree): + +``` +pytest tests/ -q -rxXs +pytest tests/ --cov=lightapi --cov-branch +ruff check lightapi +ruff format --check lightapi +mypy lightapi --strict --warn-unused-ignores +python -m build && twine check dist/* +``` + +| Measure | Original v0.1.29 (`f6d727b`) | Audit start (`804d716`) | +|---|---|---| +| pytest 3.10 | 327 passed, 1 xfailed, 2 xpassed, 544 warnings | 568 passed, 19 skipped, 1 xfailed, 2 xpassed, 715 warnings | +| pytest 3.13 (local `.venv`) | not run (SQLAlchemy 2.0.30 floor needs 3.10 to 3.12) | 568 passed, 19 skipped, 1 xfailed, 2 xpassed, 714 warnings | +| coverage `--cov-branch` (3.10) | 57% | 89% | +| ruff check / format | clean / 45 files | clean / 30 files | +| mypy `--strict --warn-unused-ignores`, project config | 3 errors (`auth_service.py`, a dead module) | 0 errors | +| same, with the per-module `ignore_errors` overrides removed | 190 errors in 29 files (18 modules ignored) | 60 errors in 12 files (16 modules ignored) | +| build + twine | PASSED | PASSED | +| `lightapi/` files / lines / classes / enums / loose functions | 45 / 7516 / 95 / 2 / 78 | 30 / 4675 / 81 / 10 / 6 | +| hidden-failure script: silent `except` / `getattr` with default | 37 / 82 | 20 / 22 | +| `ruff --select` appendix set (`--ignore TRY003`) | 404 findings | 206 findings | +| `vulture --min-confidence 80` | 0 | 0 | + +The 19 skipped tests are `tests/integration/` (real Postgres/Redis, gated by env vars); +they did not exist in v0.1.29. The 2 XPASS exist in both versions. + +### HTTP responses, original vs audit start + +Same YAML (`/tmp/lightapi-audit/audit-{sync,async}.yaml`: `/books` full CRUD with an +optional int field, `/notes` GET only, `/secrets` JWT + IsAuthenticated, `/admin` JWT + +IsAdminUser, `auth.login_validator`, page_number pagination), same SQLite file, real uvicorn, +38 requests per run: `/healthz`, list empty, create, list, get, PUT, PATCH, PUT/PATCH stale +version (409), PUT without version (422), empty body (422), wrong type (422), invalid JSON +on POST and PUT, 404 on GET/PUT/DELETE, page 2, 405 (POST and PUT on a GET-only endpoint, +HEAD on a collection, GET on `/auth/login`), 401 (no token, bad token, bad login), 403 +(user on an admin endpoint, GET and POST), login 422, DELETE 204, unknown route. + +- sync, served by `lightapi serve`: 38/38 identical (status, `content-type`, `allow`, + `www-authenticate`, body with timestamps and JWTs normalised). +- async: 38/38 identical. Served through a launcher that passes + `engine=create_async_engine(url)` to `from_config`, because `lightapi serve` with + `mode: async` in the YAML fails in BOTH versions (see B6-001). +- sync vs async within each version: 0 differences. + +### Tests weakened against the original (`git diff f6d727b -- tests/`) + +31 files changed, +3920 / -72 lines. No test file deleted, no test function removed (every +`def test_*` of v0.1.29 still exists under the same name), no file with fewer `assert` +statements, xfail markers 3 in both versions, skip markers 0 -> 7 (all new integration +tests). The 72 removed lines are renames of internal imports and patch targets +(`cache_helper.*`, `_resolve_name`, `normalise_serializer`, ...) and reformatting. Nothing +to restore. + +## Tracking + +Decisão: `corrigir`, `manter` (reason), `propor`. Status: `aberto`, `corrigido`, +`justificado`, `proposta`. Line numbers are those of commit `804d716`. + +| ID | Item | Local | Problema | Decisão | Status | Commit | +|---|---|---|---|---|---|---| +| A1-001 | A1 | `lightapi/filters.py:56` | Module-level `_coerce_filter_value` | manter: `docs/api-reference/filters.md:130` teaches `from lightapi.filters import _coerce_filter_value`; pinned by `tests/test_public_api.py` | justificado | | +| A1-002 | A1 | `lightapi/cache.py:107` | Module-level `invalidate_cache_prefix` | manter: documented in `docs/advanced/caching.md:111`, patched by tests | justificado | | +| A2-001 | A2 | `lightapi/core.py:25` | `Response.__init__(status_code: int = 200)` literal default | corrigir: `HTTPStatus.OK` (same int, same signature) | corrigido | `d232ab8` | +| A4-001 | A4 | `lightapi/cache.py:76` | `RedisCacheBackend.delete` has no caller | corrigir: remove | corrigido | `bfe14c4` | +| A4-002 | A4 | `lightapi/config.py:40` | `LIGHTAPI_JWT_ALGORITHM` env fallback is unreachable: `jwt_algorithm` defaults to `"HS256"` (truthy), so `self.jwt_algorithm or environ.get(...)` never reads the env; same in v0.1.29; undocumented | corrigir: drop the dead env read | corrigido | `57dbb6d` | +| A4-003 | A4 | `lightapi/rest.py:185` | `cls.__dict__.get("queryset")` before `getattr(cls, "queryset", None)`: the first lookup is covered by the second | corrigir: one `getattr` | corrigido | `8b07b00` | +| A5-001 | A5 | `lightapi/cache.py:116` | `EndpointCache.serve` and `serve_async` repeat check/hit/call/store, differing only by the threadpool hop around Redis | manter: the prompt forbids a generic sync/async executor; only the I/O hop differs | justificado | | +| A5-002 | A5 | `lightapi/lightapi.py:284` | `_create_tables` (sync `create_all`) and `_create_tables_inside_running_loop` (own loop on a thread) | manter: different mechanisms, no shared logic | justificado | | +| A6-001 | A6 | `lightapi/session_manager.py:72` | `SessionManager` carries the "Manager" name | manter: holds engine, metadata and registry (state), public constructor kwarg `session_manager=` documented in README | justificado | | +| A6-002 | A6 | `lightapi/schema.py:100` | `SchemaFactory` is a class of static methods | manter: in `lightapi.__all__`; `build_from_reflected_table` documented and tested by name | justificado | | +| A6-003 | A6 | `lightapi/repository.py:23` | `VersionedRow` Protocol | manter: a structural type for every mapped class, used for typing only, not dispatch | justificado | | +| A7-001 | A7 | `lightapi/_dict_config_loader.py:38` | `DictConfig.build` and `LightAPIConfig.build_app` both build an app from a config | manter: two public input formats (`from_dict` is deprecated and goes in 0.2.0) | justificado | | +| A8-001 | A8 | `lightapi/lightapi.py:174` | `_login_backend` picks the login backend with `issubclass(backend, kind)` over (JWT, Basic) | manter: single construction point stays `Authentication.build_backend`; this chooses which endpoint's backend answers `/auth/login`, by class, not by name | justificado | | +| A10-003 | A10 | `lightapi/endpoint_handler.py:102` | `EndpointGuard.check` answered every failed authentication with the generic `{"detail": "Authentication required"}` + `WWW-Authenticate: Bearer`; the backend's `get_auth_error_response()` (the documented hook; JWT's text is the one the README lists; Basic's carries the `Basic realm` challenge) never ran on the guard path. Same in v0.1.29. Found by e2e scenario 04 | corrigido: the guard returns `backend.get_auth_error_response(request)`; `ApiError.AUTHENTICATION_REQUIRED` removed | corrigido | `045f74a` | +| A10-002 | A10 | `lightapi/lightapi.py:146`, `endpoint_handler.py:224` | The routes listed only the allowed methods, so Starlette answered every other verb with its text/plain 405; the JSON 405 the README documents (`{"detail": "Method Not Allowed. Allowed: GET"}`) was reachable only by HEAD. Found by e2e scenario 03 | corrigido: the routes list only the verbs they serve and the app's own 405 handler renders the JSON reply from Starlette's `Allow`. The first fix (`43826b8`) routed every verb to the handler instead: it broke two classes sharing one path and put the 405 behind the guard, the middlewares and the login rate limiter (a `GET /auth/login` used a login slot); caught by the whole-branch review and redone | corrigido | `43826b8`, `25d7643` | +| A10-001 | A10 | `lightapi/endpoint_handler.py:299`, `rate_limiter.py:165`, `core.py:157`, `authentication/basic.py:69` | Error replies built outside `ApiError`: dynamic 405 (`Allow` lists the route's verbs), 429 (public `get_rate_limit_response`), compat 403 `{"error": "not allowed"}`, Basic 401 `{"error": ...}` (public method, no internal caller); JWT/Base 401 are FROZEN | manter: each shape is a public method or a documented body | justificado | | +| B1-001 | B1 | `lightapi/_dict_config_loader.py:89` | Unknown `auth:` value in a dict config is ignored (no backend); v0.1.29 did the same with `if auth == "jwt" ... elif "basic"` | propor: `ConfigurationError` at `from_dict` (visible: an app that built now fails); alternative: keep, the path is deprecated | proposta | | +| B1-002 | B1 | `lightapi/_login.py:49` | Login body that is not JSON/UTF-8 becomes `{}` and a 422 "Field required" | manter: specific exceptions; the 422 body is contract (pinned) | justificado | | +| B1-003 | B1 | `lightapi/_login.py:61` | Malformed `Authorization: Basic` returns None, then 401 | manter: client error mapped to one reply; specific exceptions | justificado | | +| B1-004 | B1 | `lightapi/_login.py:121` | `except Exception` around the user's `login_validator` returns None (401) and logs without the traceback | manter the except (documented since 0.1.24: any exception is a 401); corrigir the log: `exc_info=True`, `# noqa: BLE001` with the reason | corrigido | `f08edc2` | +| B1-005 | B1 | `lightapi/cache.py:50,61,71,81,93,102` | `RedisCacheBackend` turns every `redis.RedisError` (and a bad cached payload) into None/False silently | corrigir: keep the fallback, log `warning(..., exc_info=True)` (prompt rule for optional dependencies) | corrigido | `4171583` | +| B1-006 | B1 | `lightapi/constants.py:63` | `HttpVerb.of` returns None for a method outside the enum (HEAD) | manter: expected absence, answered with 405 | justificado | | +| B1-007 | B1 | `lightapi/endpoint_handler.py:296` | Malformed JSON body becomes `{}` and a 422 "Field required" (`json.JSONDecodeError`, `UnicodeDecodeError` only) | manter: specific exceptions, 422 body is contract. A 422 saying "invalid JSON" would be a visible change | justificado | | +| B1-008 | B1 | `lightapi/filters.py:52` | A filter value the column type cannot parse (`?price=abc`) is passed through as the string: no rows on SQLite, a DB error (500) on Postgres; same in v0.1.29. e2e scenario 06 on Postgres hit the 500 | corrigido: `_coerce_filter_value` raises `FilterValueError`, `_list` answers 422 with `loc: ["query", param]` | corrigido | `f589312` | +| B1-009 | B1 | `lightapi/lightapi.py:311` | `except RuntimeError: return` when no loop runs | manter: `asyncio.get_running_loop()` signals absence by raising; the return defers to the startup handler | justificado | | +| B1-010 | B1 | `lightapi/lightapi.py:303` | A failed `create_all` at `build_app` is logged as a warning without traceback and the app serves 500s; same in v0.1.29 (`except Exception`) | corrigir the log (`exc_info=True`); the fail-fast is B1-011 | corrigido | `d21960f` | +| B1-011 | B1 | `lightapi/lightapi.py:303` | Same site: should `build_app` raise instead of warn? | propor: raise `ConfigurationError` from the DDL error (visible: startup fails instead of every request); alternative: keep the warning | proposta | | +| B1-012 | B1 | `lightapi/pagination.py:129` | `?page=abc` becomes page 1 | manter: PR #46 behavior, pinned by `tests/test_second_pass_characterization.py` | justificado | | +| B1-013 | B1 | `lightapi/pagination.py:184` | An invalid cursor is ignored (first page) | manter: v0.1.29 behavior, pinned by a test; 422 would be visible | justificado | | +| B1-014 | B1 | `lightapi/schema.py:157` | `NotImplementedError` from `column.type.python_type` becomes None, then `Any` with a logged warning | manter: SQLAlchemy raises it for types without a Python type; the fallback is logged | justificado | | +| B1-015 | B1 | `lightapi/table_mapping.py:126` | `get_type_hints` failure falls back to raw annotations | manter: ends in `ConfigurationError("... not in the type map")` with the field name | justificado | | +| B1-016 | B1 | `lightapi/yaml_config.py:531` | `except ValueError: return fn` for a callable without an inspectable signature | manter: builtins; the validator is still called with two args | justificado | | +| B1-017 | B1 | `lightapi/authentication/jwt.py:84`, `lightapi/cache.py:243,262` | Silent excepts inside FROZEN classes | manter: FROZEN | justificado | | +| B1-018 | B1 | `lightapi/cache.py:49` | `redis.from_url` failure (bad URL) returns no client, silently, forever | corrigir with B1-005 (log once per call) | corrigido | `4171583` | +| B1-019 | B1 | `lightapi/repository.py:85,99` | `IntegrityError` from a `unique=True` column (or any constraint) escaped `Repository.add()`/`update()`: a duplicate value was an unhandled 500 with a traceback and the session was left in the failed state. e2e scenario 17 planned the case | corrigido: the repository rolls back and raises `ConstraintViolation`; POST, PUT and PATCH answer `409 {"detail": "constraint violation"}`, sync and async (`tests/test_constraints.py`). The whole-branch review found `delete()` still unwrapped (a DELETE of a referenced row was a 500 while the docs already promised the 409) and the refusal swallowed without a trace: `delete()` is wrapped too and every refusal is logged at INFO with the table and the driver's text | corrigido | `b11e4ed`, `d373528` | +| B1-020 | B1 | `lightapi/lightapi.py:403`, `lightapi/yaml_config.py:485` | Two error paths this branch added printed the database password: the async-driver `ConfigurationError` (B6-001) echoed the raw URL, and the YAML load error (B2-012) printed pydantic's text, whose `input_value=` holds the rejected block, so `databse:` or `database: {password: ...}` leaked into container and CI logs; found by the whole-branch review | corrigido: the URL is rendered with `make_url(url).render_as_string(hide_password=True)` and the YAML error is built from `exc.errors(include_input=False, include_url=False)` as location and message only (`tests/test_audit.py::TestReviewSecretsStayOutOfErrors`) | corrigido | `0eb581f` | +| B1-021 | B1 | `lightapi/filters.py:86` | An integer filter value beyond 64 bits (`?pages=99999999999999999999`) passed `int()` and reached the driver: `OverflowError` 500 on SQLite, the same class as B1-008; whole-branch review minor 9 | corrigido: the coerced value must sit in the `INT64_MIN..INT64_MAX` range (`lightapi/constants.py`, shared with the repository's pk check) or it is the same 422 (`tests/test_audit.py::TestReviewFilterIntegerRange`, e2e scenario 06) | corrigido | `f960717` | +| B2-001 | B2 | `lightapi/filters.py:71,97,125` | `getattr(view, "_meta", {})` on a view that is always a `RestEndpoint` (has `_meta`) | corrigir: `view._meta.get("filtering")` | corrigido | `5e91407` | +| B2-002 | B2 | `lightapi/lightapi.py:324` | `getattr(cls, "_meta", {})` on a registered endpoint (validated by `_require_rest_endpoint`) | corrigir: `cls._meta` | corrigido | `5e91407` | +| B2-003 | B2 | `lightapi/rest.py:171` | `getattr(type(self), "_session_manager", None)` | corrigir: declare `_session_manager: ClassVar[SessionManager | corrigido | `5e91407` | | +| B2-004 | B2 | `lightapi/authentication/base.py:118,125` | `getattr(request.state, "user", None)` | manter: Starlette `State` raises for a missing attribute; absent means unauthenticated | justificado | | +| B2-005 | B2 | `lightapi/rest.py:78,79,105,116-121,130,187` | `getattr(Meta, ...)`, `getattr(base, "_http_method")`, `getattr(cls, "queryset")` | manter: `Meta` attributes and hooks are optional by design | justificado | | +| B2-006 | B2 | `lightapi/schema.py:213`, `lightapi/table_mapping.py:133` | `getattr(base, "__annotations__", {})` | manter: on 3.10 a class without annotations has no `__annotations__` | justificado | | +| B2-007 | B2 | `lightapi/pagination.py:187,194` | `getattr(qs, "columns_clause_froms", ())`, `getattr(row, "id", None)` | manter: the queryset and its rows come from the user's `queryset` hook | justificado | | +| B2-008 | B2 | `lightapi/filters.py:41,45` | `hasattr(col, "property")`, `getattr(col, "type", None)` | manter: SQLAlchemy exposes the type on `InstrumentedAttribute` and `Column` differently | justificado | | +| B2-009 | B2 | `lightapi/cache.py:31` | `LIGHTAPI_REDIS_URL` defaults to `redis://localhost:6379/0` | manter: documented default, same in v0.1.29 | justificado | | +| B2-012 | B2 | `lightapi/yaml_config.py` (every block model) | The YAML models used pydantic's default `extra="ignore"`: a key the format does not know (`defaults.filtering`, `meta.paginate`, a misspelt top-level key) was dropped and the app started without the feature, while `docs/getting-started/configuration.md:89` promises a typo fails at load time. Same in v0.1.29. Found by e2e scenario 12 | corrigido: every block model inherits `_Document` with `extra="forbid"`; `FieldSpec` keeps `extra="allow"` for the constraint keys | corrigido | `fb11689` | +| B2-013 | B2 | `lightapi/schema.py:138` | `patch_schema` rebuilt every field as `(Optional[annotation], None)` and dropped its `FieldInfo`, so a PATCH skipped `min_length`, `ge`, `decimal_places` and every other constraint a PUT enforces (`PATCH {"name": ""}` against `min_length=3` was a 200); older than the branch, found by the whole-branch review because it made the new B6-006 claim untrue on PATCH | corrigido: the optional copy keeps the field's `FieldInfo` (`FieldInfo.merge_field_infos(field, default=None)`), so PATCH answers 422 like PUT (`tests/test_audit.py::TestReviewPatchKeepsConstraints`, e2e scenario 17) | corrigido | `413a697` | +| B2-014 | B2 | `lightapi/yaml_config.py:383` | `FieldSpec` keeps `extra="allow"` so constraint keys pass through, but `field_info()` only forwards `FIELD_CONSTRAINT_KEYS`: a typo (`min_lenght: 3`) was dropped and the constraint vanished, the B2-012 failure in the most common place to make a typo; found by the whole-branch review | corrigido: a `model_validator` on `FieldSpec` refuses any extra key outside the constraint keys and `default`, naming it (`tests/test_audit.py::TestReviewFieldSpecUnknownKey`) | corrigido | `797c68a` | +| B2-011 | B2 | `lightapi/schema.py:254`, `table_mapping.py:228` | A reflected table required every non-auto column on POST, nullable or not, and on `reflect = "partial"` the declared `Field()` (default, constraints) was stripped before the schema was built, so `notes: str = Field(default="")` still answered 422 "Field required". Same in v0.1.29. Found by e2e scenario 11 | corrigido: nullable reflected columns are optional (None); `ReflectedTable.map` keeps the declared FieldInfos and `build_from_reflected_table(cls, table, declared)` uses them | corrigido | `6732872` | +| B2-010 | B2 | `lightapi/rest.py:197,202,217`, `endpoint_handler.py:192`, `table_mapping.py:65`, `lightapi.py:170,324` | `_meta.get("pagination")` etc. on keys `_parsed_meta` always writes | manter: `_meta` is `{}` for `base_only` classes | justificado | | +| B3-001 | B3 | `lightapi/cache.py:57-105` | `RedisCacheBackend` methods return None/False on failure | manter: "no Redis, no cache, requests still answered" is the documented contract; B1-005 adds the log | justificado | | +| B3-002 | B3 | `lightapi/_login.py:42,56,108` | `from_request`, `parse_basic_header`, `_validated_user` return None | manter: each None maps to exactly one reply (401/422) | justificado | | +| B3-003 | B3 | `lightapi/constants.py:63,87`, `config.py:64`, `yaml_config.py:375`, `table_mapping.py:140` | `HttpVerb.of`, `TableReflection.from_meta`, `permission_for`, `field_info`, `_column_for` return None | manter: None means "not configured / not applicable", not failure | justificado | | +| B4-001 | B4 | `lightapi/fields.py:9,18,25`, `lightapi/schema.py:135,235,253,255` | 7 `# type: ignore` comments that mypy reports as unused once the module is checked | corrigir: remove | corrigido | `639dd6d` | +| B4-002 | B4 | `lightapi/config.py:92-95` | `Filtering` fields typed `tuple[...]`; YAML and dict loaders pass `list` or `None` (`yaml_config.py:187-190`, `_dict_config_loader.py:106`) | corrigir: `Sequence[...] | corrigido | `b6cfdff` | | +| B4-003 | B4 | `lightapi/config.py:59` | `Authentication.jwt_extra_claims: tuple[str, ...]` receives `list \| None` from YAML (`yaml_config.py:162`) | corrigir: `Sequence[str] \| None` | corrigido | `b6cfdff` | +| B4-004 | B4 | `lightapi/config.py:104` | `Pagination.style: PaginationStyle` but a `str` is accepted and rewritten in `__post_init__` (B8-001) | corrigir: `PaginationStyle | corrigido | `07b83af` | | +| B4-005 | B4 | `lightapi/config.py:56,58` | `Authentication.backend: type \| None`, `permission: type \| dict[str, type] \| None` | corrigir: `type[BaseAuthentication]`, `type[BasePermission]`; `build_backend()` on a None backend raises `ConfigurationError` | corrigido | `b6cfdff` | +| B4-006 | B4 | `lightapi/lightapi.py:100,91` | `register(mapping: dict[str, type])`, `_endpoint_map: dict[str, type]` | corrigir: `type[RestEndpoint]` (same signature) | corrigido | `be91dd1` | +| B4-007 | B4 | `lightapi/config.py:92` | `Filtering.backends: tuple[type, ...]` | corrigir: `Sequence[type[BaseFilter]] | corrigido | `b6cfdff` | | +| B4-008 | B4 | `lightapi/core.py:26` | `Response(headers: Dict \| None)` | corrigir: `dict[str, str] \| None` | corrigido | `024d1c1` | +| B4-009 | B4 | 89 `Any` annotations (ANN401), by file: filters 16, rest 13, core 10, yaml_config 8, pagination 8, lightapi 7, table_mapping 6, others 21 | `Any` where a real type exists (`Request`, `Response | corrigido: `Select[Any]`, `Engine`/`AsyncEngine`, `Request`/`Response | None`, `VersionedRow`, `type[BasePermission]`, `FieldInfo | None`, `Column`/`Table`/`Connection`, `object`; the `Any` that stay (forwarded kwargs, JSON values, the engine whose kind is a runtime fact) carry `# noqa: ANN401` with the reason | corrigido | `c3465a4` | | +| B4-010 | B4 | `pyproject.toml:159-176` | `ignore_errors = true` for 16 modules hides 60 strict errors (v0.1.29: 18 modules, 190 errors) | corrigir: remove the modules that reach zero after B4-001..008 and B4-011; manter the rest with the reason in the TOML (`cache.py`: redis stubs; `fields.py`: pydantic `Field` overloads) | corrigido | `97a8e0b` | +| B4-011 | B4 | `lightapi/rest.py:81-92` | 7 `# type: ignore[attr-defined]` because `_process(cls: type)` sets attributes only `RestEndpoint` declares | corrigir: `cls: type[RestEndpoint]`, declare `__schema_create__`/`__schema_read__`/`_session_manager` on `RestEndpoint` | corrigido | `179c67f` | +| B4-012 | B4 | `lightapi/yaml_config.py:325` | `assert isinstance(self.methods, dict)` as type narrowing (also S101) | corrigir: pass the dict to the method | corrigido | `f8bf3c4` | +| B5-001 | B5 | `lightapi/endpoint_handler.py:254-265,276` | Per request: `getattr(cls, verb.override)` + `inspect.isfunction`, `asyncio.iscoroutinefunction(override)`, `getattr(endpoint, verb.sync_name/async_name)` | corrigir: resolve the override (and whether it is async) and the built-in CRUD function once per route in `EndpointHandler.__init__` | corrigido | `1f7edd4` | +| B5-002 | B5 | `lightapi/endpoint_handler.py:57` | `asyncio.iscoroutinefunction(middleware.process)` per middleware, twice per request | corrigir: decide once per class in `MiddlewareChain` | corrigido | `5148ac8` | +| B5-003 | B5 | `lightapi/session_manager.py:49,55,61` | `hasattr(self._local, ...)` lazy init of thread-local state | corrigir: a `threading.local` subclass whose `__init__` builds the state | corrigido | `8d19606` | +| B5-004 | B5 | `lightapi/rest.py:190` | `callable(qs_attr)` per request | manter: the `queryset` hook may be a `Select` or a function; one call | justificado | | +| B5-005 | B5 | `lightapi/rest.py:328` | `inspect.isawaitable(qs)` per async request | manter: an `async def queryset` only yields a coroutine when called | justificado | | +| B5-006 | B5 | `lightapi/schema.py:113-122` | `isinstance`/`hasattr` per row in `row_to_dict` | manter: the row shape depends on the user's queryset (entity, Row, dict) | justificado | | +| B5-007 | B5 | `lightapi/session_manager.py:90` | `hasattr(engine, "sync_engine")` | manter: once at construction; `AsyncEngine` lives in the optional `async` extra | justificado | | +| B5-008 | B5 | `lightapi/endpoint_handler.py:240` | `isinstance(result, dict)` per request | manter: an override may return a dict (documented) | justificado | | +| B5-009 | B5 | `lightapi/filters.py:41-50` | `hasattr`/`isinstance` on the column type per filter parameter | manter: SQLAlchemy type dispatch, one parameter at a time | justificado | | +| B6-001 | B6 | `lightapi/yaml_config.py:465`, `lightapi.py:115` | YAML `mode: async` with `database.url` can never start: `from_config` builds a sync `Engine`, then `register()` raises `mode='async' requires AsyncEngine, got Engine`. `lightapi serve` cannot serve an async app. Same in v0.1.29 | corrigido: `_engine_from_url(url, mode)` builds an `AsyncEngine` in async mode; a URL without an async driver is a `ConfigurationError` naming the drivers. e2e scenario 12 serves the async document | corrigido | `26a69e8` | +| B6-002 | B6 | `lightapi/config.py:125`, `cache.py:166` | `Cache.vary_on` is documented (`docs/advanced/caching.md:57-141`: "only the listed query parameters contribute to the key") but never read; the key is always the full query string. Same in v0.1.29 | propor: implement (visible: cache keys and hits change for apps that set it) or fix the docs | proposta | | +| B6-003 | B6 | `lightapi/config.py:31` | `Config.jwt_expiration` is never read: `JWTAuthentication.generate_token` (FROZEN) uses `self.expiration or 3600` | propor: remove the field in 0.2.0 or make the FROZEN class read it | proposta | | +| B6-004 | B6 | `lightapi/lightapi.py:291` | `LightApi(engine=AsyncEngine)` without `mode="async"` and without an `async def` hook: tables are never created and every request is a 500 (now with a startup warning; v0.1.29 swallowed an `AttributeError`) | corrigido: the engine type (or an async driver in the URL, also through YAML) selects async mode, as the README promises; an explicit `mode="sync"` with an async engine is a `ConfigurationError`. The docs' own async testing fixture (`docs/advanced/async.md`, `docs/advanced/middleware.md`) now passes verbatim (`tests/test_doc_snippets.py`); applied because the README states it, raised by the whole-branch review as minor 8 | corrigido | `835904a` | +| B6-005 | B6 | `lightapi/authentication/basic.py:61` | An `async def validate_credentials` on a `BasicAuthentication` subclass: `authenticate` does not await it, the coroutine is truthy, so any Basic credentials pass and `request.state.user` is a coroutine (same in v0.1.29; `LoginEndpoint` does await it). Security relevant (B11) | corrigido: `register()` refuses an `async def validate_credentials` on a `BasicAuthentication` backend with a `ConfigurationError` naming the backend (`tests/test_audit.py::TestB6AsyncBasicValidatorRefused`); JWT backends keep theirs (only the login route calls it, and it awaits) | corrigido | `e078cdb` | +| B6-006 | B6 | `lightapi/fields.py:21`, `lightapi/table_mapping.py:173` | `Field(decimal_places=N)` was stripped before pydantic saw it and the column it fed, `Numeric(scale=N)` with no precision, renders as plain `NUMERIC` on SQLite and Postgres: the option did nothing, and every doc table said it set the scale. e2e scenario 17 stored "1.999" on a `decimal_places=2` column on both DBs | corrigido: the wrapper forwards `decimal_places` to pydantic, so more places than declared is a 422 (`tests/test_audit.py::TestB6DecimalPlaces`); the five doc tables now say what happens and that the column type stays `NUMERIC` | corrigido | `48aa94d` | +| B6-007 | B6 | `lightapi/table_mapping.py:172` | `Field(decimal_places=N)` on a `float` or `int` field was silently ignored; after B6-006 forwarded the option to pydantic it made every write a 500 (`TypeError: Unable to apply constraint 'decimal_places'`, not a `ValidationError`), through the class and through YAML; found by the whole-branch review | corrigido: `DeclaredTable` refuses the option on a non-Decimal annotation with a `ConfigurationError` naming the field, where the class is defined (`tests/test_audit.py::TestReviewDecimalPlacesNeedsDecimal`) | corrigido | `ef2bea2` | +| B7-001 | B7 | `lightapi/endpoint_handler.py:92` | `EndpointGuard.check` runs `backend.authenticate` on the event loop; a Basic validator that queries a database blocks the loop | propor: run sync backends through `run_blocking` (invisible when the validator is thread-safe; breaks validators bound to a `StaticPool` SQLite) | proposta | | +| B7-002 | B7 | `lightapi/_login.py:116` | A sync `login_validator` runs on the event loop | propor: same as B7-001 | proposta | | +| B7-003 | B7 | `lightapi/lightapi.py:308-318`, `table_mapping.py:237-249` | `asyncio.run` on a worker thread while a loop is running (bug B19, both versions); e2e scenario 02 on asyncpg failed its first POST with "Future attached to a different loop" | corrigido for a served app: the lifespan awaits `create_all` on the serving loop; `build_app()` inside an already running loop (pytest-asyncio) and async reflection keep the worker thread, documented in the CHANGELOG | corrigido | `4030449` | +| B7-004 | B7 | `lightapi/table_mapping.py:280` | `Meta.reflect` on an `AsyncEngine` reflects with `asyncio.run()` at `register()`, before the serving loop exists, and the connection went back to the pool bound to that closed loop: asyncpg refuses it on the first request that draws it (the B7-003 mechanism); whole-branch review minor 11, unverified there | corrigido: the engine is disposed after the reflection unless its pool is a `StaticPool` (the one in-memory SQLite connection, which holds the database); pinned by `tests/test_audit.py::TestReviewAsyncReflectionPool` (file-based aiosqlite: the pool is empty after `register()` and the app serves) | corrigido | `d9bbd33` | +| B8-001 | B8 | `lightapi/config.py:116` | `object.__setattr__` on the frozen `Pagination` | corrigir with B4-004: no mutation | corrigido | `07b83af` | +| B8-002 | B8 | `lightapi/config.py:54` | Module global `config = Config()`, read per request by the FROZEN `JWTAuthentication` | manter: documented as `lightapi.config.config` | justificado | | +| B8-003 | B8 | `lightapi/session_manager.py:66` | Module global `_state` (metadata + registry) | manter: SQLAlchemy needs one registry per process; `reset()` is the documented hook for tests | justificado | | +| B8-004 | B8 | `lightapi/cache.py:34` | `RedisCacheBackend.shared()` process-wide client | manter: one client per process, built on first use (so `LIGHTAPI_REDIS_URL` set after import counts) | justificado | | +| B8-005 | B8 | `lightapi/rest.py:66-92`, `lightapi.py:124`, `table_mapping.py:72-78` | The metaclass and `register()` write `_meta`, `_allowed_methods`, schemas, `_session_manager` on the user's class; `_strip_field_infos` deletes class attributes | manter: that is the registration protocol; every attribute is documented in README "RestEndpoint internals" | justificado | | +| B9-001 | B9 | `lightapi/rest.py:63` | `if name == "RestEndpoint"` skips the root class by name | corrigir: `if not bases` (the root class has no bases) | corrigido | `5e91407` | +| B9-002 | B9 | `lightapi/constants.py:72` | `getattr(HttpMethod, verb.value)` builds the verb-to-mixin map | corrigir: an explicit dict | corrigido | `139303e` | +| B9-003 | B9 | `lightapi/endpoint_handler.py:260,265` | `getattr(endpoint, verb.async_name)` / `sync_name` string dispatch per request | corrigir with B5-001 | corrigido | `1f7edd4` | +| B9-004 | B9 | `lightapi/yaml_config.py:495,514` | `importlib.import_module` for dotted names | manter: the YAML border; dotted paths are the documented way to name custom classes | justificado | | +| B9-005 | B9 | `lightapi/lightapi.py:394,404` | `importlib.import_module` probing `sqlalchemy.ext.asyncio` and the async driver | manter: optional-dependency check at startup with an install hint | justificado | | +| B10-001 | B10 | `lightapi/table_mapping.py:186-191` | `datetime.datetime.utcnow` as column defaults (deprecated since 3.12; `Repository` always passes the timestamps, so only inserts made outside LightAPI hit them) | corrigir: the same naive UTC value from a non-deprecated call | corrigido | `ca70112` | +| B10-002 | B10 | `lightapi/repository.py:55` | Timestamps stored and returned as naive UTC (`.replace(tzinfo=None)`) | manter: the stored format is contract (`CREATE TABLE` identical to v0.1.29); aware timestamps would be a visible change | justificado | | +| B10-003 | B10 | `lightapi/yaml_config.py:448` | `open(config_path)` | corrigir: `Path(config_path).open()` | corrigido | `3988875` | +| B11-001 | B11 | `lightapi/lightapi.py:213-218` | With `cors_origins` set: `allow_methods=["*"]`, `allow_headers=["*"]`, `allow_credentials=True` (same in v0.1.29) | propor: credentials off by default, or a documented opt-in; visible for browser clients | proposta | | +| B11-002 | B11 | `lightapi/core.py:98` | Compat `CORSMiddleware` defaults `allow_origins=["*"]` | propor: same as B11-001 (public compat class, documented default) | proposta | | +| B11-003 | B11 | `lightapi/yaml_config.py:325` | `assert` as validation | corrigir with B4-012 | corrigido | `f8bf3c4` | +| B11-004 | B11 | `lightapi/constants.py:37` | `ResponseKey.TOKEN = "token"` flagged S105 | manter: a JSON key, not a secret (`# noqa: S105`) | justificado | | +| B11-005 | B11 | `lightapi/lightapi.py:185`, `server.py:27`, `rate_limiter.py:124` | `0.0.0.0` (S104) | manter: documented defaults of `run()` and `lightapi serve`; the limiter's fallback IP when the client is unknown (`# noqa: S104`) | justificado | | +| B11-006 | B11 | `lightapi/constants.py:107` | 422 replies echo pydantic's error list including `input` | manter: the 422 body shape is contract (pinned by tests and the differential harness) | justificado | | +| B12-001 | B12 | `lightapi/cache.py:49` | `redis.from_url(..., socket_connect_timeout=1)` without `socket_timeout`: a Redis that accepts the connection and hangs blocks a worker thread for every cached request | corrigir: `socket_timeout` equal to the existing connect timeout (the only measured datum in the code base) | corrigido | `868034d` | +| B13-001 | B13 | `lightapi/repository.py:57` | `nullable_columns` runs `class_mapper(...)` on every PUT/PATCH | manter: a `functools.cache` keyed on the model class pinned every endpoint class and its engine for the life of the process (aiosqlite worker threads then kept pytest from exiting); one `class_mapper` call per PUT/PATCH stays, with the reason in a comment | justificado | `3d43b7b` | +| B13-002 | B13 | `lightapi/rest.py:198` | A filter backend instance is created per request | propor: instantiate once per `Filtering`; visible for a user backend that keeps per-request state on `self` (would then be shared across threads) | proposta | | +| B13-003 | B13 | `lightapi/cache.py:164` | `json.loads(response.body)` re-parses the body the CRUD just serialized, on every cached GET miss | manter: the public CRUD methods return `Response` objects; keeping the dict would change their contract | justificado | | +| B13-004 | B13 | `lightapi/endpoint_handler.py:42,50` | Middleware classes are instantiated per request (twice) | manter: v0.1.x contract; `Middleware` subclasses with `__init__` state rely on it | justificado | | +| B13-005 | B13 | `lightapi/endpoint_handler.py:233` | `HttpVerb.of(request.method)` (try/except) per request | manter: one enum lookup | justificado | | +| B14-001 | B14 | `lightapi/lightapi.py:126-127` | `logger.info(f"...")`, `logger.debug(f"...")` (G004) | corrigir: `%s` arguments | corrigido | `adfcd85` | +| B14-002 | B14 | `lightapi/lightapi.py:304` | `logger.warning("Table creation warning: %s", exc)` without `exc_info` | corrigir: `exc_info=True` (B1-010) | corrigido | `d21960f` | +| B14-003 | B14 | `lightapi/_login.py:122` | `logger.warning("validate_credentials raised: %s", exc)` without `exc_info` | corrigir: `exc_info=True` (B1-004) | corrigido | `f08edc2` | +| B14-004 | B14 | `lightapi/lightapi.py:211` | Test warning: Starlette `on_startup` is deprecated | corrigir: `lifespan` | corrigido | `63bcd5c` | +| B14-005 | B14 | `lightapi/table_mapping.py:108` | Test warning: `Column.copy()` is deprecated (SQLAlchemy 1.4); the copy dates from when columns were shared across re-registrations, `from_endpoint` now builds fresh columns per class | corrigir: drop the copy (verify with the e2e and isolation tests) | corrigido | `daf38fb` | +| B14-006 | B14 | `pytest.ini:4` | Test warning: `PytestConfigWarning: Unknown config option: env` (pytest-env is not installed; the values were never applied). `pytest.ini` also shadows `[tool.pytest.ini_options]` in `pyproject.toml`, so `xfail_strict` must go where pytest reads it | corrigir: move `asyncio_mode` and `markers` to `pyproject.toml`, delete `pytest.ini`, add `xfail_strict = true` | corrigido | `d1ee2a8` | +| B14-007 | B14 | 11 test modules (e.g. `tests/test_async_crud.py` and `tests/test_second_pass_characterization.py` both define `Widget`) | 13 `SAWarning: This declarative base already contains a class with the same class name and module name` | corrigir in tests: unique class names per module | corrigido | `56a7ebd` | +| B14-008 | B14 | `lightapi/lightapi.py:325` | `warnings.warn(RuntimeWarning)` when Redis is unreachable at startup | manter: a user-facing startup warning, pinned by `tests/test_cache_v2.py` | justificado | | +| B14-009 | B14 | `Dockerfile:58` | The image's HEALTHCHECK probed `/`, a 404 on every LightAPI app, so docker reported the container unhealthy no matter what (B20); e2e scenario 16 waits for `healthy` | corrigido: the probe hits `/healthz`, the liveness route the app always registers, over a raw socket: with the path fixed alone, scenario 16 still read `unhealthy` because `import urllib.request` took 1.8 to 2.9 s inside the image on Docker Desktop (measured 3 runs) and the probe exceeded the timeout; the socket probe measures 0.42 to 0.53 s import plus 0.24 to 0.26 s request, so the 3 s timeout keeps 3.8x headroom; scenario 16 proves `docker inspect` reports `healthy` | corrigido | `a3c2695`, `c37b9c9` | +| B15-001 | B15 | 27 ARG002/ARG003 sites (`retrieve(request, pk)`, `destroy(request, pk)`, `_retrieve_async`, `_destroy_async`, base-class `authenticate`/`has_permission`/`validate_credentials`/`from_config`, `get_rate_limit_response(request)`, `HealthCheckEndpoint`, `BaseCache` FROZEN, `Middleware.process`) | Unused parameters | manter: every one is a public signature (pinned by `tests/data/public_api.json`) or an interface method; each carries `# noqa: ARG002`/`ARG003` with the reason; `BaseCache` (FROZEN) is exempted at module level in `cache.py` | justificado | `c3465a4` | +| B15-002 | B15 | `lightapi/config.py:60`, `authentication/jwt.py:37`, `basic.py:30` | `Authentication.rate_limiter` is stored on the backend and never read | propor: remove in 0.2.0 (public field) | proposta | | +| B15-003 | B15 | `lightapi/__init__.py:60`, `lightapi/rate_limiter.py:52` | ERA001: section labels inside `__all__` and a structure comment read as commented-out code | corrigir: sort `__all__` without labels (RUF022), reword the comment | corrigido | `efd6160` | +| B15-004 | B15 | `lightapi/__init__.py:30` | `# noqa: E402` no longer needed (RUF100) | corrigir: remove | corrigido | `efd6160` | +| B15-005 | B15 | `lightapi/rest.py:244,270`, `yaml_config.py:246` | Internal methods with a positional boolean (`_update(..., partial)`, `_update_values(..., partial, ...)`, `build_meta_class(..., reflect)`) | corrigir: keyword-only; the public `update(data, pk, partial=False)` stays | corrigido | `841ff4a` | +| B16-001 | B16 | `tests/test_filtering.py:223`, `tests/test_rest.py:204` | `xfail` markers on tests that pass (XPASS in both versions) | corrigir: remove the markers, enable `xfail_strict` | corrigido | `c5a387f` | +| B16-002 | B16 | `tests/test_cache_v2.py:144` | `if len(keys) >= 2: assert ...` can pass without asserting | corrigir: assert the count, then the keys | corrigido | `a7f3086` | +| B16-003 | B16 | `tests/test_serializer.py:96-97`, `tests/test_pipeline.py:97-126` | Tests read `_meta["serializer_normalised"]` | manter: `_meta` is documented as the parsed `Meta` dict (README); the tests pin the projection a declared `Meta` yields | justificado | | +| B16-004 | B16 | `tests/test_cache_v2.py`, `tests/test_cache_async.py` | Tests patch `RedisCacheBackend.get/set/invalidate_prefix/ping` (own code) | manter: it is the Redis boundary wrapper; a real Redis is covered by `tests/integration/test_redis_cache.py` | justificado | | +| B16-005 | B16 | `tests/integration/` | 19 tests skipped without `LIGHTAPI_TEST_PG_*` / `LIGHTAPI_REDIS_URL` | manter: need real services; the skip reason names the variable | justificado | | + +## Phase 3: verification (audit end, commit `83d12a2`) + +Same commands as Phase 0. Python 3.10 through `uv run --isolated --frozen --python 3.10 +--extra dev`, Python 3.13 through the local `.venv`. + +| Measure | Original v0.1.29 (`f6d727b`) | Audit start (`804d716`) | Audit end (`83d12a2`) | +|---|---|---|---| +| pytest 3.10 | 327 passed, 1 xfailed, 2 xpassed, 544 warnings | 568 passed, 19 skipped, 1 xfailed, 2 xpassed, 715 warnings | 589 passed, 19 skipped, 1 xfailed, 0 warnings | +| pytest 3.13 | not run | 568 passed, 19 skipped, 1 xfailed, 2 xpassed, 714 warnings | 589 passed, 19 skipped, 1 xfailed, 0 warnings | +| coverage `--cov-branch` (3.10) | 57% | 89% | 90% | +| ruff check, project config | clean (`E,F,W,I`) | clean (`E,F,W,I`) | clean (the appendix set, see below) | +| `ruff --select` appendix set (`--ignore TRY003`) on `lightapi/` | 404 findings | 206 findings | 0 | +| same set on `tests/` and `examples/` with the per-file ignores below | not measured | 187 findings | 0 | +| ruff format | 45 files clean | 30 files clean | 106 files clean (`lightapi`, `tests`, `examples`) | +| mypy, project config | 3 errors (dead `auth_service.py`) | 0 | 0 | +| mypy `--strict --warn-unused-ignores`, no `ignore_errors` overrides | 190 errors in 29 files (18 modules ignored) | 60 in 12 files (16 modules ignored) | 5 in 1 file (1 module ignored: `lightapi.cache`, redis-py stubs) | +| `[tool.mypy]` modules with `ignore_errors` | 18 | 16 | 1 | +| build + twine | PASSED | PASSED | PASSED | +| `mkdocs build --strict` | not measured | not measured | passes | +| `lightapi/` files / lines / classes / enums / loose functions | 45 / 7516 / 95 / 2 / 78 | 30 / 4675 / 81 / 10 / 6 | 30 / 4943 / 84 / 10 / 6 | +| radon raw: LOC / LLOC / SLOC / comment lines | 7516 / 4037 / 5024 / 294 | 4675 / 2642 / 3127 / 166 | 4943 / 2730 / 3336 / 242 | +| hidden-failure script: silent `except` / `getattr` with default | 37 / 82 | 20 / 22 | 1 / 2 | +| `vulture --min-confidence 80` | 0 | 0 | 0 | +| xfail / xpass | 1 / 2 | 1 / 2 | 1 / 0, with `xfail_strict = true` | +| pytest warnings of any kind | 544 | 715 | 0, with `filterwarnings = ["error", ...]` | + +The three hidden-failure sites that remain are the documented fallbacks of +`DeclaredTable._field_annotations` (`get_type_hints` on a string annotation that cannot be +evaluated) and the two `getattr(base, "__annotations__", {})` over an MRO that includes +`object`. + +### HTTP responses, original vs audit end + +Same procedure as Phase 0 (`/tmp/lightapi-audit/final-{sync,async}.json` against +`orig-{sync,async}.json`): 38/38 identical in sync, 38/38 identical in async. The only byte +that differs is the port inside the `previous` pagination URL of step `list-page-2`, which +is the port each run was served on. + +### Tests weakened + +None. No test deleted, skipped or loosened. Changes to existing tests, all in their own +commits: `B14-007` (unique class names per module; fixture-built endpoint classes moved to +module scope; async fixtures dispose their engines), `B16-001` (two `xfail` markers removed +from tests that pass), `B16-002` (the vary_on test asserts its two writes), and the lint +conformance edits of `83d12a2` (combined `with` blocks, `Path.unlink`, `ClassVar` on test +doubles, `yaml.YAMLError` instead of `Exception`, raw regex strings, `# noqa` with a reason +on the subprocess, urlopen and forced-exception lines). `tests/test_audit.py` adds 21 tests, +one per fixed ID, in the commit that fixes the ID. + +### Where the result departs from the brief + +- **Lines of `lightapi/` grew**: 4675 -> 4943 physical lines (+268, +5.7%), 2642 -> 2730 + logical lines (+88). Of the physical growth, +76 are comment lines (the `# noqa` reasons + and the comments the brief asks for on every justified line) and most of the rest is + ruff format wrapping a signature onto several lines once it carries a trailing reason. + The logical growth is the fixes themselves: `_ThreadState`, `_Override` and `_BoundVerb` + (B5), `LightApi._lifespan` (B14-004), `_model_class_or_raise` (B4-011), the spelled-out + `HTTP_METHOD_MIXINS` (B9-002), the typed locals mypy needed where `Any` used to flow. + Nothing was added that the tracking table does not name. +- **FROZEN classes**: `BaseCache.get/set` and `RedisCache.get/set` keep their behaviour and + signatures; ruff's autofix changed the spelling of two annotations + (`Optional[Dict[str, Any]]` -> `dict[str, Any] | None`, strings at runtime under + `from __future__ import annotations`; `tests/test_public_api.py` still passes), and + `cache.py` exempts `ARG002` at module level instead of adding comments inside the classes. +- **Per-file ignores** beyond the brief, with the reason in `pyproject.toml`: tests also + ignore `S105`, `S106`, `S107` (56 literal test passwords) and `E501` (29 long assertions, + as before); examples also ignore `S101`, `S105`, `SLF001`, `ARG002` and `E501` (smoke + asserts, demo credentials, `type(self)._model_class` as the docs teach, the documented + `queryset(self, request)` signature, curl commands). The tests glob is `tests/**/*.py` + so `tests/integration/` is covered. +- **`filterwarnings = ["error", "ignore::ResourceWarning", ...]`**: every library or + pytest warning fails the run; `ResourceWarning` keeps Python's default (ignored) because + the tests build throwaway engines they never `dispose()`, which is test hygiene the brief + did not ask to change (56 tests would need a teardown). +- **Commit `5e91407`** (B2-001..003) also carries the B9-001 hunk (`if not bases:` instead + of `if name == "RestEndpoint"`); the tracking table points B9-001 at it. +- **Classes 81 -> 84**: `_ThreadState` (B5-003), `_Override` and `_BoundVerb` (B5-001), + all private. + +### End-to-end curl suite (branch `test/e2e-curl`) + +`tools/e2e/run_all.sh`: 17 bash scenarios, each a real server process (an `apps/*.py` +module, `python -m lightapi serve` on a YAML document, or the container image) driven by +`curl` and checked with `jq`, on a SQLite file and, for the 8 scenarios tagged `pg`, again +on a throwaway `postgres:16` container. Full runs on 2026-10-11, all green and identical: + +| Run | Result | +|---|---| +| Python 3.13.2, twice | `21 ok, 0 failed, 1 skipped` (400 steps; the skip is the docker scenario without `E2E_DOCKER=1`) | +| Python 3.10.19 (`UV_PYTHON=3.10 UV_PROJECT_ENVIRONMENT=.venv310`) | `21 ok, 0 failed, 1 skipped` (same 400 steps) | +| `E2E_DOCKER=1 tools/e2e/run_all.sh docker` | `1 ok` (7 steps: image built from this checkout, healthy, uid 1001) | +| each of the 16 non-docker scripts alone | ok | + +What the suite forced, each fixed in the library with a unit test in its own commit before +the scenario that found it: B7-003 (`4030449`, asyncpg lifespan loop), A10-002 (`43826b8`, +JSON 405), A10-003 (`045f74a`, backend 401), B1-008 (`f589312`, filter value 422), B2-011 +(`6732872`, reflected defaults), B6-001 (`26a69e8`, async from a URL), B2-012 (`fb11689`, +unknown YAML keys), B1-019 (`b11e4ed`, constraint 409), B6-006 (`48aa94d`, `decimal_places` +enforced), B14-009 (`a3c2695`, `c37b9c9`, container health probe), plus the 403 body in +the docs (`9f87562`). + +A whole-branch review by a fresh reader (0 critical, 7 important, 8 minor findings) then +forced one fix pass, each fix with a test that failed first: A10-002 redone (`25d7643`, +the first fix routed every verb to the handler and broke two classes on one path and put +the 405 behind the guard), B1-019 extended (`d373528`, DELETE of a referenced row is 409 +too, every refusal logged), B2-013 (`413a697`, PATCH validates field constraints), +B6-007 (`ef2bea2`, `decimal_places` on a non-Decimal field refused at class definition), +B1-020 (`0eb581f`, no database password in error messages), B2-014 (`797c68a`, a typo +under `fields.` is refused). Four of the minor findings were then fixed the same way: +B6-004 (`835904a`, async mode detected from the engine or the URL, which also makes the +docs' own async testing fixture pass verbatim), B6-005 (`e078cdb`, an async validator on a +Basic backend is refused), B1-021 (`f960717`, integer filter beyond 64 bits is 422), B7-004 +(`d9bbd33`, async reflection disposes its pool). Still open from the review: the Basic 401 +body uses `error` (now documented as such), and `docs/refactor-log.md` is a historical log. +Known gaps: none; `tools/e2e/README.md` keeps that line. + +### Proposals (not applied: each changes visible behaviour or touches a FROZEN class) + +- B1-001: `ConfigurationError` from `from_dict` on an unknown `auth:` value instead of silently no auth. +- B1-008: 422 for a filter value the column type cannot parse instead of passing the string through. +- B1-011: raise from a failed `CREATE TABLE` at `build_app()` instead of a warning plus 500s. +- B6-001: `from_config` builds an `AsyncEngine` when `mode: async` (today `lightapi serve` cannot start an async app). +- B6-002: implement `Cache.vary_on`, which the docs describe and nothing reads, or fix the docs. +- B6-003: `Config.jwt_expiration` is never read; remove in 0.2.0 or make FROZEN `JWTAuthentication` read it. +- B6-004: an `AsyncEngine` without `mode="async"` yields 500s; detect the mode from the engine or refuse at register. +- B6-005: an `async def validate_credentials` on `BasicAuthentication` lets any credentials through; refuse at startup or await it. +- B7-001, B7-002: sync auth backends and `login_validator` run on the event loop; move them to `run_blocking`. +- B7-003: `asyncio.run` on a worker thread while a loop runs (table creation, reflection); refuse and document. +- B11-001, B11-002: CORS `allow_credentials=True` with `*` origins by default; opt-in instead. +- B13-002: filter backends are instantiated per request; once per `Filtering` would share state across threads. +- B15-002: `Authentication.rate_limiter` is stored on the backend and never read; remove in 0.2.0. diff --git a/docs/diagram.md b/docs/diagram.md new file mode 100644 index 0000000..cbdc837 --- /dev/null +++ b/docs/diagram.md @@ -0,0 +1,1583 @@ +# LightAPI class diagrams + +This page is the class-level map of the `lightapi/` package: one snapshot taken before the object-oriented refactor and one that will be added when it ends. Every diagram is a Mermaid block; mkdocs renders them through the `mermaid` custom fence of `pymdownx.superfences` (`mkdocs.yml:121-125`). + +## Before the refactor + +Snapshot of commit `d24c7f3` on branch `refactor/oo-second-pass`, the tree at the moment the refactor started: 35 Python files (`git ls-files lightapi` = 35), 4647 lines in total. Every file and line cited below was read from that tree; check a citation with `git show d24c7f3:lightapi/`, not against a later working tree. While this page was being written the working tree had already started to move (`rest.py`, `constants.py`, `lightapi.py`, `endpoint_handler.py`, `cache_helper.py`, `_login.py`, `rate_limiter.py`, `core.py`, `authentication/basic.py` changed on disk), so some of the facts in the Diagnosis check, in particular claims 5 and 10, are already being fixed. + +### Module overview + +Solid arrows are top-level imports, dashed arrows are imports made inside a function body. Imports that only exist under `if TYPE_CHECKING:` are not drawn; they are listed after the diagram. Modules with no intra-package imports (`constants`, `exceptions`, `core`, `methods`, `fields`, `filters`, `health`, `middleware_runner`, `session`, `session_manager`, `repository`, `rate_limiter`) appear only as targets. + +```mermaid +flowchart LR + subgraph startup [Startup and configuration] + init["__init__.py"] & main["__main__.py"] & server["server.py"] & app["lightapi.py"] & health["health.py"] + yloader["yaml_loader.py"] & yschema["yaml_schema.py"] & ynames["yaml_names.py"] & dictloader["_dict_config_loader.py"] + end + subgraph request [Request path and data] + handler["endpoint_handler.py"] & guard["auth_checker.py"] & mw["middleware_runner.py"] & chelper["cache_helper.py"] & cache["cache.py"] + rest["rest.py"] & schema["schema.py"] & tmap["table_mapping.py"] & repo["repository.py"] & pag["pagination.py"] & filters["filters.py"] + session["session.py"] & smgr["session_manager.py"] + end + subgraph auth [Authentication] + authpkg["authentication/__init__.py"] & abase["authentication/base.py"] & abasic["authentication/basic.py"] & ajwt["authentication/jwt.py"] + login["_login.py"] & rl["rate_limiter.py"] & authcompat["auth.py"] + end + subgraph leaves [Leaf modules] + config["config.py"] & constants["constants.py"] & exceptions["exceptions.py"] & core["core.py"] & methods["methods.py"] & fields["fields.py"] + end + main --> server + server --> app & exceptions + init --> authpkg & cache & config & core & exceptions & fields & filters & app & methods & rl & rest & schema & session + app --> login & authpkg & handler & exceptions & health & rl & rest & smgr & yloader + app -.-> dictloader & cache + yloader --> exceptions & ynames & yschema + yloader -.-> config & filters & rest & fields + ynames --> exceptions + ynames -.-> authcompat & core & filters & methods + yschema --> exceptions + dictloader -.-> init & authcompat & config & methods + handler --> guard & chelper & constants & mw & rest & session + guard --> authpkg & constants + chelper --> cache + cache --> constants + rest --> constants & exceptions & pag & repo & schema & session & tmap + schema --> constants & exceptions + schema -.-> config + tmap --> exceptions & schema + pag --> constants + config --> constants & exceptions & pag + config -.-> authpkg + authpkg --> abase & abasic & ajwt + abase --> constants + abasic --> login & abase + ajwt --> abase & config & constants + login --> constants + authcompat --> authpkg +``` + +`TYPE_CHECKING`-only imports (type hints, never executed): `table_mapping.py:17` -> `session_manager`; `config.py:19-20` -> `authentication`, `rate_limiter`; `endpoint_handler.py:23` -> `authentication.base`; `_login.py:18-19` -> `authentication.base`, `rate_limiter`; `auth_checker.py:14-15` -> `authentication.base`, `config`; `authentication/base.py:13` -> `config`; `authentication/basic.py:14-15` -> `config`, `rate_limiter`; `authentication/jwt.py:17-18` -> `config`, `rate_limiter`. + +The second diagram shows the three pieces of process-wide state and every external dependency, with the module that touches each one. + +```mermaid +flowchart LR + subgraph pkg [lightapi modules] + app["lightapi.py"] & server["server.py"] & yloader["yaml_loader.py"] & yschema["yaml_schema.py"] + smgr["session_manager.py"] & tmap["table_mapping.py"] & session["session.py"] & repo["repository.py"] & pag["pagination.py"] + ajwt["authentication/jwt.py"] & cache["cache.py"] & chelper["cache_helper.py"] + end + cfgstate(["config.config, a Config instance, config.py:54"]) + sessstate(["session_manager._state, a _GlobalState, session_manager.py:63"]) + cachestate(["cache._default_backend, lru_cache maxsize 1, cache.py:85-92"]) + sqla[("SQLAlchemy engine, MetaData, registry, Session")] + redis[("Redis server")] + pyjwt["PyJWT"] + pyyaml["PyYAML"] + uvicorn["uvicorn"] + env_db["env LIGHTAPI_DATABASE_URL"] + env_jwt["env LIGHTAPI_JWT_SECRET and LIGHTAPI_JWT_ALGORITHM"] + env_redis["env LIGHTAPI_REDIS_URL"] + env_cfg["env LIGHTAPI_CONFIG, LIGHTAPI_HOST, LIGHTAPI_PORT"] + env_yaml["env var named by a placeholder in database.url"] + ajwt -- jwt_secret_value, jwt_algorithm_value --> cfgstate + cfgstate --> env_jwt + ajwt --> pyjwt + smgr -- metadata, registry, unique_table_name --> sessstate + cache --> cachestate + chelper --> cache + cachestate --> redis & env_redis + app -- create_engine, create_all --> sqla + app --> env_db & uvicorn + smgr --> sqla + tmap -- Table, inspect, reflect --> sqla + session -- Session, AsyncSession --> sqla + repo --> sqla + pag --> sqla + yloader -- yaml.safe_load --> pyyaml + yschema -- _substitute_env --> env_yaml + server -- ServerCommand.from_env --> env_cfg + classDef ext fill:#e0e0e0,stroke:#777,color:#222 + classDef state fill:#fff4ce,stroke:#c9a227,stroke-dasharray: 4 3,color:#222 + class sqla,redis,pyjwt,pyyaml,uvicorn,env_db,env_jwt,env_redis,env_cfg,env_yaml ext + class cfgstate,sessstate,cachestate state +``` + +Where each piece of global state is read or written: + +- `config.config` (`config.py:54`): read by `JWTAuthentication.authenticate` (`authentication/jwt.py:74, 81`) and `JWTAuthentication.generate_token` (`jwt.py:117, 127`). Its properties read `LIGHTAPI_JWT_SECRET` (`config.py:38`) and `LIGHTAPI_JWT_ALGORITHM` (`config.py:42-44`) on every call. +- `session_manager._state` (`session_manager.py:63`): read by `SessionManager.__init__` (`session_manager.py:85-90`) and `SessionManager.table_name_for` (`session_manager.py:114`). It holds the one `MetaData` and `registry` shared by every non-isolated app, plus a thread-local pair and table-name counters for `use_test_isolation=True`. +- `cache._default_backend` (`cache.py:85-92`): an `lru_cache(maxsize=1)` function that builds one `RedisCacheBackend` on first call; read by `_ping_redis`, `get_cached`, `set_cached`, `invalidate_cache_prefix` (`cache.py:97, 102, 107, 112`). `RedisCacheBackend.__init__` reads `LIGHTAPI_REDIS_URL` (`cache.py:20-22`). + +Other environment reads: `LIGHTAPI_DATABASE_URL` in `lightapi._engine_from_url` (`lightapi.py:324`); `LIGHTAPI_CONFIG`, `LIGHTAPI_HOST`, `LIGHTAPI_PORT` in `ServerCommand.from_env` (`server.py:37, 43, 44`); any `${VAR}` placeholder in `database.url` in `yaml_schema._substitute_env` (`yaml_schema.py:25-35`). + +Small modules not drawn in detail below: `exceptions.py` (`ConfigurationError`, `SerializationError`), `methods.py` (`HttpMethod` namespace with inner marker classes `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, each carrying `_http_method`), `fields.py` (`Field(**kwargs) -> FieldInfo`, strips the five LightAPI column kwargs into `json_schema_extra`), `core.py` (legacy `Response`, `Middleware`, `CORSMiddleware`, `AuthenticationMiddleware`; nothing in the package imports it except the YAML name registry), `auth.py` (re-export of `lightapi.authentication`), `__main__.py` (calls `server.main`). + +### Core + +Modules: `lightapi.py`, `rest.py`, `schema.py`, `table_mapping.py`, `repository.py`. + +```mermaid +classDiagram + class LightApi { + -_engine Any + -_mode str + -_session_manager SessionManager + -_login_validator LoginValidator or None + -_rate_limiter_global RateLimiter or None + -_routes list~Route~ + -_endpoint_map dict~str,type~ + -_middlewares list~type~ + -_cors_origins list~str~ + -_auth_path str + +register(mapping: dict~str,type~) None + +run(host: str, port: int, debug: bool, reload: bool) None + +build_app(debug: bool) Starlette + +from_config(config_path: str, **kwargs)$ LightApi + +from_dict(config: dict, **kwargs)$ LightApi + -_register_endpoint(path: str, cls: type, app_context: AppContext) None + -_login_routes() list~Route~ + -_login_backend() BaseAuthentication or None + -_asgi_routes() list~Route~ + -_create_tables() None + -_check_cache_connections() None + } + class SessionManager { + -_engine Any + -_is_async bool + -_use_test_isolation bool + -_metadata MetaData + -_registry registry + +engine Any + +metadata MetaData + +registry registry + +is_async bool + +table_name_for(base_name: str) str + +create_tables_now() None + } + class _GlobalState { + -_local threading.local + +metadata MetaData + +registry registry + +test_metadata MetaData + +test_registry registry + +reset() None + +unique_table_name(base_name: str) str + } + class HealthCheckEndpoint { + +get(request: Request) JSONResponse + } + LightApi --> SessionManager : owns one + SessionManager ..> _GlobalState : reads module _state + LightApi ..> EndpointHandler : two per endpoint, in _register_endpoint + LightApi ..> LoginEndpoint : one, in _login_routes + LightApi ..> RestEndpoint : sets cls._session_manager, maps table + LightApi ..> HealthCheckEndpoint : routes at /healthz + note for LightApi "mode is the string sync or async, compared in 7 places" +``` + +```mermaid +classDiagram + class RestEndpointMeta { + <> + +__new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs) type + -_process(cls: type, name: str, namespace: dict)$ None + } + class RestEndpoint { + -_model_class type or None$ + -_table_source TableSource$ + -_meta dict~str,Any~$ + -_allowed_methods set~str~$ + -_session_manager SessionManager$ + -__schema_create__ type$ + -__schema_read__ type$ + -_background BackgroundTasks or None + -_current_request Request or None + +background(fn: Callable, *args, **kwargs) None + +list(request: Request) Response + +retrieve(request: Request, pk: int) Response + +create(data: dict) Response + +update(data: dict, pk: int, partial: bool) Response + +destroy(request: Request, pk: int) Response + -_list(session: Session, request: Request, qs: Any) Response + -_retrieve(session: Session, pk: int) Response + -_create(session: Session, data: dict) Response + -_update(session: Session, data: dict, pk: int, partial: bool) Response + -_update_values(data: dict, partial: bool, nullable_columns: set~str~) dict + -_destroy(session: Session, pk: int) Response + -_repository(session: Session) Repository + -_serialize_row(row: Any, method: str) dict + -_run_filter_backends(request: Request, qs: Any) Any + -_get_engine() Any + -_get_queryset(request: Request) Any + -_get_async_engine() Any + -_get_queryset_async(request: Request) Any + -_in_async_session(work: Callable) Response + -_list_async(request: Request) Response + -_retrieve_async(request: Request, pk: int) Response + -_create_async(data: dict) Response + -_update_async(data: dict, pk: int, partial: bool) Response + -_destroy_async(request: Request, pk: int) Response + } + RestEndpointMeta ..> RestEndpoint : builds every subclass + RestEndpoint --> TableSource : _table_source + RestEndpoint ..> Repository : one per CRUD call + RestEndpoint ..> SchemaFactory : build at class creation + RestEndpoint ..> NoPagination : when Meta has no pagination + note for RestEndpoint "_meta keys: authentication, filtering, pagination, serializer_normalised, cache, reflect, table" +``` + +```mermaid +classDiagram + class SchemaFactory { + +build(cls: type)$ tuple of two types + +build_from_reflected_table(cls: type, table: Any)$ tuple of two types + } + class TableSource { + <> + +map(endpoint_cls: type, session_manager: SessionManager) None + } + class DeclaredTable { + -_columns list~Column~ + +map(endpoint_cls: type, session_manager: SessionManager) None + } + class ReflectedTable { + -_partial bool + +map(endpoint_cls: type, session_manager: SessionManager) None + -_reflect(table_name: str, session_manager: SessionManager) Table + } + class Repository { + -_model type~VersionedRow~ + -_session Session + +nullable_columns set~str~ + +get(pk: int) Any + +add(values: dict) Any + +update(pk: int, expected_version: int, values: dict) Any + +delete(pk: int) None + } + class VersionedRow { + <> + +id Any + +version Any + } + class RowNotFound { + <> + } + class VersionConflict { + <> + } + DeclaredTable ..|> TableSource + ReflectedTable ..|> TableSource + ReflectedTable ..> SchemaFactory : build_from_reflected_table after reflection + DeclaredTable ..> SessionManager : create_tables_now, registry.map_imperatively + Repository --> VersionedRow : _model + Repository ..> RowNotFound : raises + Repository ..> VersionConflict : raises +``` + +Module-level functions in the Core modules: + +`lightapi.py` (constants `_LOGIN_RATE_LIMITS` lines 35-39, `_ASYNC_HANDLER_NAMES` line 320): + +- `_engine_from_url(database_url: str | None) -> Any` (323): reads `LIGHTAPI_DATABASE_URL`; called by `LightApi.__init__` (69). +- `_checked_mode(mode: str | None) -> str` (333): compares against the literals `"sync"`/`"async"`; called by `__init__` (70). +- `_as_rate_limiter(value: RateLimiter | dict[str, int] | None) -> RateLimiter | None` (342): reads `_LOGIN_RATE_LIMITS`; called by `__init__` (75). +- `_require_rest_endpoint(path: str, cls: Any) -> None` (353): called by `register` (95). +- `_has_async_handlers(cls: type) -> bool` (361): reads the attributes named in `_ASYNC_HANDLER_NAMES` on the endpoint class; called by `register` (98). +- `_require_async_engine(engine: Any) -> None` (369): called by `register` (101). +- `_validate_async_dependencies(engine: Any) -> None` (383): reads `engine.url.get_dialect().name`; called by `run` (176). + +`rest.py` (constants `_AUTO_FIELDS` 48, `_TYPE_MAP` 50-58, `_ALL_METHODS` 60, `_VERSION_ADAPTER` 66, `_ERROR_RESPONSES` 251-254): + +- `_is_optional(annotation: Any) -> tuple[bool, Any]` (69): called by `_column_for` (181). +- `_reject_redeclared_auto_fields(name: str, namespace: dict) -> None` (132): reads `namespace["__annotations__"]`; called by `RestEndpointMeta._process` (111). +- `_field_annotations(cls: type) -> dict[str, Any]` (141): walks `cls.__mro__`; called by `_declared_columns` (164). +- `_declared_columns(cls: type, name: str, namespace: dict) -> list[Column]` (160): called by `_process` (127). +- `_column_for(endpoint_name: str, field_name: str, annotation: Any, field_info: Any) -> Column | None` (173): reads `_TYPE_MAP` and the `FieldInfo.json_schema_extra` written by `fields.Field`; called by `_declared_columns` (167). +- `_auto_columns() -> list[Column]` (201): called by `_process` (129). +- `_parsed_meta(name: str, meta_obj: Any) -> dict[str, Any]` (216): builds the `cls._meta` dict with its seven keys (230-238), calls `normalise_serializer`; called by `_process` (117). +- `_allowed_methods(cls: type) -> set[str]` (241): reads `_http_method` on every MRO base; called by `_process` (118). +- `_error_response(error: Exception) -> Response` (257): reads `_ERROR_RESPONSES`; called by `_retrieve` (364), `_update` (401), `_destroy` (425). +- `_unprocessable(errors: Any) -> Response` (262): called by `_create` (371), `_update` (384, 390, 396). + +`schema.py` (constants `_AUTO_FIELDS` 18, `_REFLECTED_PYTHON_TYPES` 108-120, `_AUTO_READ_FIELDS` 148-153): + +- `normalise_serializer(serializer: object) -> tuple[list[str] | None, list[str] | None, list[str] | None]` (21) (imported by tests, `tests/test_serializer.py:12`): imports `config.Serializer` inside the function (29); called by `rest._parsed_meta` (234). +- `resolve_fields(cls: type, method: str) -> list[str] | None` (51) (imported by tests, `tests/test_pipeline.py:6`): reads `cls._meta.get("serializer_normalised")` (53); called by `RestEndpoint._serialize_row` (340). +- `_row_to_dict(row: Any) -> dict[str, Any]` (61) (imported by tests, `tests/test_pipeline.py:6`): called by `_serialize_row` (339). +- `_apply_fields(d: dict, fields: list[str] | None) -> dict` (82) (imported by tests, `tests/test_pipeline.py:6`): called by `_serialize_row` (341). +- `patch_schema(schema_create: type[BaseModel]) -> type[BaseModel]` (90, `@lru_cache(maxsize=None)` at 89) (imported by tests, `tests/test_repository.py:10`): called by `RestEndpoint._update_values` (413). +- `_reflected_annotation(column: Any) -> Any` (123): called by `SchemaFactory.build_from_reflected_table` (240). +- `_schema_pair(endpoint_name: str, create_fields: dict, read_fields: dict) -> tuple[type, type]` (156): reads `_AUTO_READ_FIELDS`; called by `SchemaFactory.build` (226) and `build_from_reflected_table` (246). + +`table_mapping.py`: + +- `_table_name(endpoint_cls: type, session_manager: SessionManager) -> str` (86): reads `endpoint_cls._meta.get("table")` (87) and `session_manager.table_name_for`; called by `DeclaredTable.map` (38) and `ReflectedTable.map` (63). +- `_strip_field_infos(endpoint_cls: type, columns: list[Column]) -> None` (91): deletes `FieldInfo` and `...` class attributes; called by `DeclaredTable.map` (36) and `ReflectedTable.map` when partial (66). +- `_reflect_sync(engine: Any, metadata: MetaData, table_name: str) -> Table` (102): called by `ReflectedTable._reflect` (81). +- `_reflect_async(engine: Any, metadata: MetaData, table_name: str) -> Table` (110): runs `asyncio.run` or a one-thread pool; called by `ReflectedTable._reflect` (78). + +`repository.py` (constants `FIRST_VERSION` 11, `_MIN_PK`/`_MAX_PK` 16-17): + +- `_in_pk_range(pk: int) -> bool` (20): called by `Repository.get` (61), `update` (83), `delete` (101). +- `_utcnow() -> datetime.datetime` (111): called by `Repository.add` (72) and `update` (89). + +### Request path + +Modules: `endpoint_handler.py`, `auth_checker.py`, `cache_helper.py`, `middleware_runner.py`, `session.py`. + +```mermaid +classDiagram + class AppContext { + <> + +middlewares list~type~ + +is_async bool + +login_validator LoginValidator or None + } + class _Verb { + <> + +override str + +sync_name str + +async_name str + +args Callable + +reads_body bool + +cached bool + } + class EndpointHandler { + -_endpoint_cls type~RestEndpoint~ + -_verbs dict~str,_Verb~ + -_middlewares list~type~ + -_is_async bool + -_guard EndpointGuard + +for_collection(endpoint_cls: type, app: AppContext)$ EndpointHandler + +for_detail(endpoint_cls: type, app: AppContext)$ EndpointHandler + +methods list~str~ + +handle(request: Request) Response + -_serve(verb: _Verb, endpoint: RestEndpoint, request: Request) Any + -_call_override(override: Callable, endpoint: RestEndpoint, request: Request) Any + -_method_not_allowed() Response + } + class EndpointGuard { + -_authentication Authentication or None + -_login_validator LoginValidator or None + +check(request: Request) Response or None + } + class ResponseCache { + -_cls type + -_cache_cfg Cache or None + +serve(request: Request, fn: Callable) Response + +serve_async(request: Request, fn: Callable) Response + +invalidate_after_write(request: Request) None + -_cached_response(request: Request) Response or None + -_store_response(request: Request, response: Response) None + -_key(request: Request) str + -_key_prefix() str + } + EndpointHandler --> AppContext : copies middlewares, is_async + EndpointHandler --> EndpointGuard : one, built in __init__ + EndpointHandler --> _Verb : _COLLECTION_VERBS or _DETAIL_VERBS + EndpointHandler ..> ResponseCache : new instance per request, up to twice + EndpointHandler ..> RestEndpoint : new instance per request + EndpointGuard ..> Authentication : requires_login, permission_for, build_backend + ResponseCache ..> RedisCacheBackend : via get_cached, set_cached, invalidate_cache_prefix + note for _Verb "Two module dicts hold the six verbs: _COLLECTION_VERBS GET, POST and _DETAIL_VERBS GET, PUT, PATCH, DELETE" +``` + +Module-level functions: + +- `endpoint_handler.py`: five argument adapters with the same signature `(request: Request, data: dict, pk: Any) -> tuple[Any, ...]`: `_request_only` (47), `_data_only` (51), `_request_and_pk` (55), `_full_update` (59, returns `(data, pk, False)`), `_partial_update` (63, returns `(data, pk, True)`). They are the `args` field of the `_Verb` entries in `_COLLECTION_VERBS` (67-70) and `_DETAIL_VERBS` (72-79), called from `EndpointHandler._serve` (147). `async _read_body(request: Request) -> dict[str, Any]` (191): `except Exception` returns `{}`; called by `_serve` (146). +- `middleware_runner.py`: `async run_pre_middlewares(middlewares: list[type], request: Request) -> Response | None` (9) and `async run_post_middlewares(middlewares: list[type], request: Request, response: Response) -> Response` (24). Both instantiate every middleware class (`mw_cls()`, lines 14 and 29) on every request and accept a sync or async `process`; called from `EndpointHandler.handle` (121, 141). +- `session.py` (constant `_SINGLE_CONNECTION_POOLS` 21): `get_sync_session(engine: Engine)` context manager (24-33, commit on exit, rollback and re-raise on exception), `get_async_session(engine: AsyncEngine)` async context manager (36-47, imports `AsyncSession` inside the function), `async run_blocking(engine: Engine, fn: Callable, *args) -> T` (50): runs `fn` in place when the engine pool is `SingletonThreadPool` or `StaticPool`, otherwise in Starlette's thread pool; called from `EndpointHandler._serve` (166, 167) and `_call_override` (180). `get_sync_session` and `get_async_session` are exported from `lightapi/__init__.py:30` and used by `RestEndpoint.list/retrieve/create/update/destroy` (435-455) and `_in_async_session` (489). + +One request, from the Starlette route to the database. The left column says what already exists when the request arrives (built once in `LightApi.register`) and what is created on this request. + +```mermaid +sequenceDiagram + participant S as Starlette Route + participant H as EndpointHandler + participant M as middleware classes + participant G as EndpointGuard + participant E as RestEndpoint instance + participant C as ResponseCache + participant R as Repository + participant DB as Session and engine + Note over H,G: built once per route in register(). Also once: table mapping, schemas, _meta, _allowed_methods + S->>H: handle(request) + H->>E: endpoint_cls() with a new BackgroundTasks + Note over E: new instance per request + H->>M: run_pre_middlewares(middlewares, request) + Note over M: every middleware class is instantiated again per request + M-->>H: None, or an early Response + H->>G: check(request) + G->>G: authentication.build_backend(login_validator) + Note over G: a new auth backend object per request when login is required + G-->>H: None, or a 401 or 403 response + H->>H: verb = _verbs[request.method], data = _read_body(request) + alt sync app + H->>C: ResponseCache(cls).serve(request, fn) inside run_blocking + Note over C: new ResponseCache per request + C->>E: list() / retrieve() / create() / update() / destroy() + E->>DB: get_sync_session(engine) + else async app + H->>C: ResponseCache(cls).serve_async(request, fn) + C->>E: _list_async() and friends + E->>DB: get_async_session(engine) then session.run_sync(work) + end + Note over E: _list builds pagination.build_paginator() and one backend_cls() per filter backend, per request + E->>R: Repository(model_class, session) + Note over R: new Repository per CRUD call + R->>DB: select, insert, update, delete + R-->>E: row, or RowNotFound / VersionConflict + E-->>H: Response + H->>C: ResponseCache(cls).invalidate_after_write(request) + Note over C: second ResponseCache of the request + H->>M: run_post_middlewares(middlewares, request, response) + H-->>S: Response +``` + +Created once, in `LightApi.register` -> `_register_endpoint` (`lightapi.py:109-133`): the table mapping (`cls._table_source.map`, 111), two `EndpointHandler`s (115-116) each with its `EndpointGuard` (`endpoint_handler.py:95-97`), and two Starlette `Route`s (117-132). Earlier still, at class-definition time in `RestEndpointMeta._process` (`rest.py:110-129`): `cls._meta`, `cls._allowed_methods`, the create and read schemas (declared tables) and the `TableSource`. + +Created per request: the `RestEndpoint` instance and its `BackgroundTasks` (`endpoint_handler.py:117-118`), one instance of each middleware class twice (pre and post, `middleware_runner.py:14, 29`), the authentication backend (`auth_checker.py:36` via `config.py:95`), up to two `ResponseCache` objects (`endpoint_handler.py:136, 157, 165`), the paginator (`rest.py:355`), one instance of each filter backend class (`rest.py:334`), the `Repository` per CRUD call (`rest.py:429`), and the `Session` (`rest.py:435-455, 489`). + +### Authentication + +Modules: `config.py` (`Authentication`, `Config`), `authentication/base.py`, `authentication/basic.py`, `authentication/jwt.py`, `_login.py`, `auth.py`, `rate_limiter.py`. + +```mermaid +classDiagram + class Authentication { + <> + +backend type or None + +permission type or dict~str,type~ or None + +jwt_expiration int or None + +jwt_extra_claims tuple~str~ + +jwt_algorithm str or None + +rate_limiter RateLimiter or None + +permission_for(method: str) type + +requires_login(method: str) bool + +build_backend(login_validator: Callable or None) BaseAuthentication + } + class Config { + <> + +jwt_secret str or None + +jwt_algorithm str + +jwt_expiration int + +jwt_secret_value str or None + +jwt_algorithm_value str + } + class BaseAuthentication { + +from_config(authentication: Authentication, login_validator: LoginValidator or None)$ BaseAuthentication + +login_response(user: dict) dict + +authenticate(request: Request) bool + +get_auth_error_response(request: Request) JSONResponse + +validate_credentials(username: str, password: str) dict or None + } + class BasicAuthentication { + +rate_limiter RateLimiter or None + -_login_validator LoginValidator or None + +from_config(authentication: Authentication, login_validator: LoginValidator or None)$ BasicAuthentication + +authenticate(request: Request) bool + +get_auth_error_response(request: Request) JSONResponse + +validate_credentials(username: str, password: str) dict or None + } + class JWTAuthentication { + +algorithm str or None + +expiration int or None + +rate_limiter RateLimiter or None + +extra_claims tuple~str~ + +from_config(authentication: Authentication, login_validator: LoginValidator or None)$ JWTAuthentication + +login_response(user: dict) dict + +authenticate(request: Request) bool + +get_auth_error_response(request: Request) JSONResponse + +validate_credentials(username: str, password: str) dict or None + +generate_token(payload: dict, expiration: int or None) str + } + BasicAuthentication --|> BaseAuthentication + JWTAuthentication --|> BaseAuthentication + Authentication ..> BaseAuthentication : build_backend calls backend.from_config + JWTAuthentication ..> Config : reads module global config.config + BasicAuthentication ..> LoginRequest : _parse_basic_header from _login.py +``` + +```mermaid +classDiagram + class BasePermission { + +has_permission(request: Request) bool + } + class AllowAny + class IsAuthenticated + class IsAdminUser + AllowAny --|> BasePermission + IsAuthenticated --|> BasePermission + IsAdminUser --|> BasePermission + class LoginRequest { + <> + +username str + +password str + } + class LoginEndpoint { + -_backend BaseAuthentication + -_login_validator LoginValidator or None + -_rate_limiter RateLimiter + +handle(request: Request) JSONResponse + -_validated_user(username: str, password: str) dict or None + } + class RateLimiter { + +requests_per_minute int + +requests_per_hour int + +requests_per_day int + -_store dict of ip to window to timestamp to count + -_cleanup_interval int + -_last_cleanup float + +reset() None + +is_rate_limited(request: Request, endpoint: str) tuple of bool and str or None + +get_rate_limit_response(request: Request, window: str) JSONResponse + -_cleanup_old_entries() None + -_get_window_seconds(window: str) int + -_get_client_ip(request: Request) str + } + LoginEndpoint --> RateLimiter : is_rate_limited, get_rate_limit_response + LoginEndpoint --> BaseAuthentication : login_response, validate_credentials + LoginEndpoint ..> LoginRequest : via _parse_credentials + EndpointGuard ..> AllowAny : identity check, permission is not AllowAny + note for RateLimiter "windows are the strings minute, hour, day, compared in 5 places" +``` + +How a backend is built: `Authentication.build_backend(login_validator)` (`config.py:92-95`) calls `self.backend.from_config(self, login_validator)`. `BaseAuthentication.from_config` (`base.py:25-35`) returns `cls()`; `BasicAuthentication.from_config` (`basic.py:33-44`) and `JWTAuthentication.from_config` (`jwt.py:40-54`) pass the `Authentication` settings to their constructors, unless the subclass overrides `__init__`, in which case `cls()` is used. `build_backend` has two callers: `EndpointGuard.check` (`auth_checker.py:36`, once per request that requires login) and `LightApi._login_backend` (`lightapi.py:160`, once per app, for the login routes). + +`LoginEndpoint` (`_login.py:79-140`) answers `POST {auth_path}/login` and `POST {auth_path}/token` (`lightapi.py:144-147`). Order inside `handle`: rate limit (98-102), method check (104-109), credentials from a Basic header or a `LoginRequest` body (112-117, 422 on a pydantic error), `_validated_user` (119, which prefers the app's `login_validator` over the backend's `validate_credentials` and awaits an awaitable result), then `backend.login_response(user)` (125). + +Module-level functions of `_login.py`: `_parse_basic_header(auth_header: str) -> tuple[str, str] | None` (33, called by `BasicAuthentication.authenticate` at `basic.py:54` and by `_parse_credentials` at 63), `async _parse_credentials(request: Request) -> tuple[str, str] | None` (52, called by `LoginEndpoint.handle` 112), `async _read_body(request: Request) -> dict[str, Any]` (70, a second copy of `endpoint_handler._read_body` with a narrower `except`, called by `_parse_credentials` 65). + +Import cycle: `authentication/jwt.py:13` imports `config` at module level (`from lightapi.config import config`), and `authentication/__init__.py:14` imports `jwt`. `config.py` needs `AllowAny` from `lightapi.authentication` for `permission_for` and `requires_login`, so it imports it inside both methods (`config.py:72` and `config.py:84`) instead of at the top; the comment at `config.py:71` names the cycle. The type-only imports at `config.py:19-20` sit under `TYPE_CHECKING` for the same reason. + +### Cache + +Modules: `cache.py`, `cache_helper.py`, `config.py` (`Cache`). + +```mermaid +classDiagram + class Cache { + <> + +ttl int + +vary_on tuple~str~ + +__post_init__() None + } + class RedisCacheBackend { + -_redis_url str + -_client redis.Redis or None + -_get_client() redis.Redis or None + +get(key: str) dict or None + +set(key: str, value: dict, timeout: int) bool + +delete(key: str) bool + +invalidate_prefix(prefix: str) bool + +ping() bool + } + class ResponseCache { + -_cls type + -_cache_cfg Cache or None + +serve(request: Request, fn: Callable) Response + +serve_async(request: Request, fn: Callable) Response + +invalidate_after_write(request: Request) None + } + class BaseCache { + <> + +get(key: str) dict or None + +set(key: str, value: dict, timeout: int) bool + } + class RedisCache { + <> + +client redis.Redis + +get(key: str) dict or None + +set(key: str, value: dict, timeout: int) bool + -_get_cache_key(key: str) str + } + class RedisClient { + <> + } + RedisCache --|> BaseCache + ResponseCache ..> Cache : reads cls._meta cache + ResponseCache ..> RedisCacheBackend : get_cached, set_cached, invalidate_cache_prefix + RedisCacheBackend ..> RedisClient : redis.from_url, built lazily + note for RedisCacheBackend "one process-wide instance behind cache._default_backend, lru_cache maxsize 1" + note for Cache "vary_on is declared at config.py:133 and read nowhere in the package" +``` + +Module-level functions of `cache.py`: `_default_backend() -> RedisCacheBackend` (85-92, `@lru_cache(maxsize=1)`, the global), `_ping_redis() -> bool` (95, called by `LightApi._check_cache_connections` through an in-function import at `lightapi.py:308-310`), `get_cached(key: str) -> Any | None` (100), `set_cached(key: str, value: Any, ttl: int) -> None` (105), `invalidate_cache_prefix(prefix: str) -> None` (110). The last three are called by `ResponseCache` (`cache_helper.py:61, 73, 52`); `invalidate_cache_prefix` is also documented for user code in `docs/advanced/caching.md:111`. + +`ResponseCache` (`cache_helper.py:13-82`) is a no-op when `cls._meta.get("cache")` is `None` (26, 39, 50). The key is `lightapi:{ClassName}:{path}:{query}` (77-79); invalidation deletes `lightapi:{ClassName}:*` after any non-GET request (48-52). Only `JSONResponse` with status 200 is stored (67). Redis errors are swallowed in both the backend (`cache.py:29, 40, 50, 60, 72, 81`) and the helper (`cache_helper.py:62, 74`). + +`BaseCache` and `RedisCache` (`cache.py:115-215`) are exported from `lightapi/__init__.py:12, 65` and have no caller inside the package. + +### Pagination and filters + +Modules: `pagination.py`, `filters.py`, `config.py` (`Pagination`, `Filtering`). + +```mermaid +classDiagram + class Pagination { + <> + +style str + +page_size int + +__post_init__() None + +build_paginator() Paginator + } + class Paginator { + <> + +render(request: Request, qs: Any, session: Session, serialize: RowSerializer) dict + } + class NoPagination { + +render(request: Request, qs: Any, session: Session, serialize: RowSerializer) dict + } + class PageNumberPaginator { + +page_size int + +render(request: Request, qs: Any, session: Session, serialize: RowSerializer) dict + +paginate(request: Request, qs: Any, session: Session, page_size: int) tuple of rows and total + +paginate_async(request: Request, qs: Any, session: AsyncSession, page_size: int) tuple of rows and total + +wrap(request: Request, results: list, total: int, page: int, page_size: int) dict + } + class CursorPaginator { + +page_size int + +render(request: Request, qs: Any, session: Session, serialize: RowSerializer) dict + +paginate(request: Request, qs: Any, session: Session, page_size: int) tuple of rows and cursor + +paginate_async(request: Request, qs: Any, session: AsyncSession, page_size: int) tuple of rows and cursor + +wrap(results: list, next_cursor: str or None, prev_cursor: str or None) dict + } + NoPagination ..|> Paginator + PageNumberPaginator ..|> Paginator + CursorPaginator ..|> Paginator + Pagination ..> PageNumberPaginator : PAGINATORS page_number + Pagination ..> CursorPaginator : PAGINATORS cursor + RestEndpoint ..> Pagination : _meta pagination, build_paginator per request + note for Pagination "style is validated against VALID_PAGINATION_STYLES, a tuple of two strings" +``` + +Module-level names of `pagination.py`: `encode_cursor(last_id: int) -> str` (18) and `decode_cursor(cursor: str) -> int` (22), both `base64(json({"id": last_id}))`, called only by `CursorPaginator.paginate` (146, 166); `_current_page(request: Request) -> int` (26), reads `?page=` and falls back to 1 on a non-numeric value, called twice per page-number request, by `PageNumberPaginator.render` (66) and `paginate` (77); `RowSerializer` alias (35); `PAGINATORS: dict[str, type]` (194-197) mapping `"page_number"` and `"cursor"` to the two classes, read by `Pagination.build_paginator` (`config.py:125`). `paginate_async` on both paginators is not called anywhere in the package; `render` always receives a sync `Session` because the async path goes through `AsyncSession.run_sync` (`rest.py:490`). + +```mermaid +classDiagram + class Filtering { + <> + +backends tuple~type~ + +fields tuple~str~ + +search tuple~str~ + +ordering tuple~str~ + } + class BaseFilter { + +RESERVED_PARAMS frozenset~str~$ + +LIKE_ESCAPE_CHAR str$ + +filter_queryset(request: Request, queryset: Any, view: Any) Any + -_escape_like(value: str)$ str + -_coerce_filter_value(col: Any, value: str)$ Any + } + class FieldFilter { + +filter_queryset(request: Request, queryset: Any, view: Any) Any + } + class SearchFilter { + +filter_queryset(request: Request, queryset: Any, view: Any) Any + } + class OrderingFilter { + +filter_queryset(request: Request, queryset: Any, view: Any) Any + } + FieldFilter --|> BaseFilter + SearchFilter --|> BaseFilter + OrderingFilter --|> BaseFilter + RestEndpoint ..> Filtering : _meta filtering + RestEndpoint ..> BaseFilter : backend_cls() per backend per request + FieldFilter ..> Filtering : view._meta filtering fields + SearchFilter ..> Filtering : view._meta filtering search + OrderingFilter ..> Filtering : view._meta filtering ordering +``` + +Module-level function of `filters.py`: `_coerce_filter_value(col: Any, value: str) -> Any` (55-62), an alias of `BaseFilter._coerce_filter_value` (33-52). It is documented as importable for custom backends in `docs/api-reference/filters.md:125-133` and recorded in the public-API snapshot `tests/data/public_api.json:198`, so it is public despite the underscore. The three filter backends read `view._meta.get("filtering")` (`filters.py:69, 95, 125`) and `type(view)._model_class` (80, 105, 139); `RestEndpoint._run_filter_backends` (`rest.py:329-335`) instantiates each backend class on every request (334). + +### Configuration + +Modules: `config.py`, `yaml_schema.py`, `yaml_loader.py`, `yaml_names.py`, `_dict_config_loader.py`, `server.py`, `health.py`, `constants.py`. + +```mermaid +classDiagram + class Serializer { + +fields list~str~ or None + +read list~str~ or None + +write list~str~ or None + +__init_subclass__(**kwargs)$ None + +__init__(fields: list or None, read: list or None, write: list or None) None + } + class Config { + <> + +jwt_secret str or None + +jwt_algorithm str + +jwt_expiration int + } + class Authentication { + <> + } + class Filtering { + <> + } + class Pagination { + <> + } + class Cache { + <> + } + class ServerCommand { + +DEFAULT_HOST str$ + +DEFAULT_PORT int$ + -_config_path str + -_host str + -_port int + +from_env(env: Mapping)$ ServerCommand + -_parse_port(raw: str or None)$ int + +execute() None + } + class HTTPStatus { + <> + } + class ResponseKey { + <> + } + class HealthCheckEndpoint { + <> + +get(request: Request) JSONResponse + } + ServerCommand ..> LightApi : from_config then run + Serializer ..> ConfigurationError : raises on fields with read or write + note for Serializer "normalise_serializer in schema.py turns any form into a 3-tuple stored in _meta" +``` + +`constants.py`: `HTTPStatus(int, Enum)` (7-18) with `OK`, `CREATED`, `NO_CONTENT`, `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `CONFLICT`, `UNPROCESSABLE_ENTITY`, `TOO_MANY_REQUESTS`; `ResponseKey(str, Enum)` (24-32) with `DETAIL`, `RESULTS`, `TOKEN`, `USER`, `COUNT`, `PAGES`, `NEXT`, `PREVIOUS`; plain constants `DEFAULT_PAGE_SIZE` (38), `PAGE_PARAM` (39), `CURSOR_PARAM` (40), `VALID_PAGINATION_STYLES` (41), `AUTO_FIELDS` (46), `DEFAULT_JWT_ALGORITHM` (51), `DEFAULT_JWT_EXPIRATION` (52), `VALID_JWT_ALGORITHMS` (53-65), `DEFAULT_REDIS_URL` (70), `DEFAULT_CACHE_TTL` (71). + +`server.py`: `ServerCommand` (19-59) reads `LIGHTAPI_CONFIG`, `LIGHTAPI_HOST`, `LIGHTAPI_PORT` in `from_env` (36-45) and calls `LightApi.from_config(path).run(host, port)` in `execute` (58-59). `main(argv: list[str] | None = None) -> None` (62-68) accepts only the `serve` command; it is the `lightapi` console script (`pyproject.toml:81`) and the target of `__main__.py:3`. + +`health.py`: `HEALTH_PATH = "/healthz"` (9) and `HealthCheckEndpoint.get` returning `{"status": "ok"}` (20-21), routed last by `LightApi._asgi_routes` (`lightapi.py:211`). + +The YAML side is a set of pydantic models plus loose builder functions that turn them into `RestEndpoint` subclasses. First the root document and one endpoint entry: + +```mermaid +classDiagram + class LightAPIConfig { + +database DatabaseConfig or None + +cors_origins list~str~ + +defaults DefaultsConfig + +endpoints list~EndpointConfig~ + +middleware list~str~ + +auth AuthLoginConfig or None + +mode str or None + +effective_database_url str or None + } + class DatabaseConfig { + +url str + +substitute_env(v: str)$ str + } + class AuthLoginConfig { + +auth_path str + +login_validator str or None + } + class DefaultsConfig { + +authentication AuthConfig or None + +pagination PaginationConfig or None + } + class EndpointConfig { + +route str + +fields dict~str,FieldSpec~ + +reflect bool + +meta MetaConfig + +require_route() EndpointConfig + +effective_route str + } + class FieldSpec { + +type str + +optional bool + +type_must_be_known(v: str)$ str + } + class MetaConfig + class AuthConfig + class PaginationConfig + LightAPIConfig *-- DatabaseConfig + LightAPIConfig *-- DefaultsConfig + LightAPIConfig *-- EndpointConfig + LightAPIConfig *-- AuthLoginConfig + DefaultsConfig *-- AuthConfig + DefaultsConfig *-- PaginationConfig + EndpointConfig *-- FieldSpec + EndpointConfig *-- MetaConfig + note for FieldSpec "extra keys are allowed and forwarded to Field() by _build_endpoint_class" +``` + +Then the `meta:` block of an endpoint, which mirrors the `config.py` dataclasses one to one: + +```mermaid +classDiagram + class MetaConfig { + +methods list~str~ or dict~str,MethodAuthConfig~ + +authentication AuthConfig or None + +filtering FilteringConfig or None + +pagination PaginationConfig or None + +cache CacheConfig or None + +serializer SerializerConfig or None + +table str or None + } + class AuthConfig { + +backend str or None + +permission str or dict~str,str~ or None + +jwt_expiration int or None + +jwt_extra_claims list~str~ or None + +jwt_algorithm str or None + +merged_over(defaults: AuthConfig or None) AuthConfig + } + class MethodAuthConfig { + +authentication AuthConfig or None + } + class FilteringConfig { + +backends list~str~ + +fields list~str~ + +search list~str~ + +ordering list~str~ + } + class PaginationConfig { + +style str + +page_size int + } + class CacheConfig { + +ttl int + } + class SerializerConfig { + +fields list~str~ or None + +read list~str~ or None + +write list~str~ or None + } + MetaConfig *-- MethodAuthConfig + MetaConfig *-- AuthConfig + MetaConfig *-- FilteringConfig + MetaConfig *-- PaginationConfig + MetaConfig *-- CacheConfig + MetaConfig *-- SerializerConfig +``` + +`yaml_schema.py` also holds `YAML_FIELD_TYPES` (14-22, seven type names) and the loose function `_substitute_env(value: str) -> str` (25-35), called only by the `DatabaseConfig.substitute_env` validator (46). The model `mode` (182) and `PaginationConfig.style` (89) are plain `str` fields; they are validated later, by `lightapi._checked_mode` and `config.Pagination.__post_init__`. + +`yaml_loader.py`, eleven functions, in call order from the entry point: + +- `load_config(app_cls: type, config_path: str, **overrides) -> Any` (304): `yaml.safe_load`, `LightAPIConfig.model_validate`, resolves middleware names, builds the `LightApi` and registers the endpoints; called by `LightApi.from_config` (`lightapi.py:229`). +- `_build_endpoint_class(entry: EndpointConfig, defaults: DefaultsConfig) -> type` (231): class name from the route (238-240), `Field(**kwargs)` per `FieldSpec` (245-286), then `type(class_name, bases, class_attrs)` (296); called by `load_config` (352). +- `_build_meta_class(meta: MetaConfig, defaults: DefaultsConfig, reflect: bool) -> type` (152): builds the inner `Meta` class; called by `_build_endpoint_class` (289). +- `_resolve_methods_bases(meta: MetaConfig) -> tuple[type, ...]` (206): `RestEndpoint` plus one `HttpMethod` mixin per listed verb via `name_registry()`; called by `_build_endpoint_class` (293). +- `_make_authentication(auth_cfg: AuthConfig | None, defaults_auth: AuthConfig | None) -> Any` (46): list form of `methods`; called by `_build_meta_class` (169). +- `_per_method_authentication(meta: MetaConfig, defaults: DefaultsConfig) -> Any` (57): dict form of `methods`; called by `_build_meta_class` (167). +- `_permission_name(method: str, method_auth: AuthConfig | None, default_permission: str | dict | None) -> str | None` (80): called by `_per_method_authentication` (70). +- `_resolved_permission(permission: str | dict[str, str] | None) -> Any` (93): `resolve_name` on one name or on each value of a per-method dict; called by `_make_authentication` (54). +- `_authentication_from(settings: AuthConfig, permission: Any) -> Any` (102): builds `config.Authentication`; called by `_make_authentication` (54) and `_per_method_authentication` (77). +- `_make_filtering(filtering_cfg: FilteringConfig | None) -> Any` (114): auto-selects `FieldFilter`/`SearchFilter`/`OrderingFilter` when no backends are named (127-133); called by `_build_meta_class` (173). +- `_make_pagination(pag_cfg: PaginationConfig | None) -> Any` (143): called by `_build_meta_class` (179). + +`yaml_names.py`, three functions: `name_registry() -> dict[str, type]` (11-45) builds the short-name table (five auth classes, three filters, three middlewares, five `HttpMethod` mixins) on every call, importing them inside the function; `resolve_callable(dotted_path: str) -> Any` (48-97) imports a dotted path and checks it is a sync callable with exactly two required positional parameters, used for `auth.login_validator` (`yaml_loader.py:341`); `resolve_name(name: str) -> Any` (100-120) looks up the registry then falls back to a dotted import, used for backends, permissions, filters and middleware (`yaml_loader.py:72, 96, 98, 106, 123, 326`). + +`_dict_config_loader.py`, two functions: `load_from_dict(app_cls: type, config: dict[str, Any], **overrides) -> Any` (11-147), the body of the deprecated `LightApi.from_dict` (`lightapi.py:255-264`), which builds endpoint classes from `{"fields": {...}, "auth": "jwt" | "basic", "paginate": ..., "filters": ..., "methods": [...]}` entries with its own copy of the method-name map (125-131); `_route_to_class_name(route: str) -> str` (150-153). + +### Owner of each module-level function + +| Function | Module | Data it uses | Proposed owner | +|---|---|---|---| +| `_engine_from_url` | lightapi.py:323 | `database_url`, env `LIGHTAPI_DATABASE_URL` | `LightApi` | +| `_checked_mode` | lightapi.py:333 | the mode string, literals `"sync"`/`"async"` | enum `ExecutionMode` | +| `_as_rate_limiter` | lightapi.py:342 | a `RateLimiter` or limits dict, `_LOGIN_RATE_LIMITS` | `RateLimiter.from_limits` | +| `_require_rest_endpoint` | lightapi.py:353 | path and class from the `register` mapping | `LightApi` | +| `_has_async_handlers` | lightapi.py:361 | endpoint attributes named in `_ASYNC_HANDLER_NAMES` | `LightApi` | +| `_require_async_engine` | lightapi.py:369 | the engine type | `LightApi` | +| `_validate_async_dependencies` | lightapi.py:383 | `engine.url` dialect, installed drivers | `LightApi` | +| `_is_optional` | rest.py:69 | one field annotation | `DeclaredTable` | +| `_field_annotations` | rest.py:141 | `cls.__mro__`, `__annotations__`, `AUTO_FIELDS` | `DeclaredTable` | +| `_declared_columns` | rest.py:160 | class namespace, `FieldInfo` values | `DeclaredTable` | +| `_column_for` | rest.py:173 | annotation, `FieldInfo.json_schema_extra`, `_TYPE_MAP` | `DeclaredTable` | +| `_auto_columns` | rest.py:201 | nothing (fixed four columns) | `DeclaredTable` | +| `_reject_redeclared_auto_fields` | rest.py:132 | `namespace["__annotations__"]`, `AUTO_FIELDS` | `RestEndpointMeta` | +| `_parsed_meta` | rest.py:216 | the `Meta` inner class attributes | `RestEndpointMeta` | +| `_allowed_methods` | rest.py:241 | `_http_method` on MRO bases, `_ALL_METHODS` | `RestEndpointMeta` | +| `_error_response` | rest.py:257 | `_ERROR_RESPONSES`, the exception type | enum `ApiError` | +| `_unprocessable` | rest.py:262 | pydantic error list | enum `ApiError` | +| `normalise_serializer` | schema.py:21 | a `Serializer` instance or subclass | `FieldProjection` (NamedTuple) | +| `resolve_fields` | schema.py:51 | `cls._meta["serializer_normalised"]`, the HTTP method | `FieldProjection` (NamedTuple) | +| `_apply_fields` | schema.py:82 | a row dict and the field list | `FieldProjection` (NamedTuple) | +| `_row_to_dict` | schema.py:61 | ORM instance, `Row`, dict or object | `SchemaFactory` | +| `patch_schema` | schema.py:90 | the create schema's `model_fields` | `SchemaFactory` | +| `_schema_pair` | schema.py:156 | endpoint name, field dicts, `_AUTO_READ_FIELDS` | `SchemaFactory` | +| `_reflected_annotation` | schema.py:123 | a SQLAlchemy `Column.type`, `_REFLECTED_PYTHON_TYPES` | `SchemaFactory` | +| `_table_name` | table_mapping.py:86 | `endpoint_cls._meta["table"]`, class name, `SessionManager.table_name_for` | `DeclaredTable` / `ReflectedTable` (shared) | +| `_strip_field_infos` | table_mapping.py:91 | class `__dict__`, column names | `DeclaredTable` / `ReflectedTable` (shared) | +| `_reflect_sync` | table_mapping.py:102 | engine, metadata, table name | `ReflectedTable` | +| `_reflect_async` | table_mapping.py:110 | async engine, metadata, table name, running loop | `ReflectedTable` | +| `_utcnow` | repository.py:111 | the clock | `Repository` | +| `_in_pk_range` | repository.py:20 | `_MIN_PK`, `_MAX_PK` | `Repository` | +| `_request_only` | endpoint_handler.py:47 | request | `_Verb` | +| `_data_only` | endpoint_handler.py:51 | parsed body | `_Verb` | +| `_request_and_pk` | endpoint_handler.py:55 | request, path `id` | `_Verb` | +| `_full_update` | endpoint_handler.py:59 | body, path `id` | `_Verb` | +| `_partial_update` | endpoint_handler.py:63 | body, path `id` | `_Verb` | +| `_read_body` | endpoint_handler.py:191 | `request.body()` | `EndpointHandler` | +| `run_pre_middlewares` | middleware_runner.py:9 | middleware classes, request | `MiddlewareChain` | +| `run_post_middlewares` | middleware_runner.py:24 | middleware classes, request, response | `MiddlewareChain` | +| (none; `EndpointGuard` is already a class) | auth_checker.py | `Authentication`, `login_validator` | stays `EndpointGuard` | +| `_default_backend` | cache.py:85 | env `LIGHTAPI_REDIS_URL` via `RedisCacheBackend()` | `RedisCacheBackend.shared()` | +| `_ping_redis` | cache.py:95 | the shared backend | `RedisCacheBackend.shared()` | +| `get_cached` | cache.py:100 | the shared backend, a key | `EndpointCache` | +| `set_cached` | cache.py:105 | the shared backend, key, value, ttl | `EndpointCache` | +| `invalidate_cache_prefix` | cache.py:110 | the shared backend, a prefix | stays (documented in `docs/advanced/caching.md:111`) | +| `encode_cursor` | pagination.py:18 | last row id | `Cursor` value object | +| `decode_cursor` | pagination.py:22 | cursor string | `Cursor` value object | +| `_current_page` | pagination.py:26 | `request.query_params["page"]` | `PageNumberPaginator` | +| `run_blocking` | session.py:50 | `engine.pool` type, a callable | `SessionManager` | +| `get_sync_session` | session.py:24 | an `Engine` | stays (public, `__init__.py:30`) | +| `get_async_session` | session.py:36 | an `AsyncEngine` | stays (public, `__init__.py:30`) | +| `_parse_basic_header` | _login.py:33 | `Authorization` header | `LoginRequest` | +| `_parse_credentials` | _login.py:52 | header or JSON body | `LoginRequest` | +| `_read_body` | _login.py:70 | `request.body()` | `LoginRequest` | +| `_make_authentication` | yaml_loader.py:46 | `AuthConfig`, defaults `AuthConfig` | `AuthConfig` | +| `_per_method_authentication` | yaml_loader.py:57 | `MetaConfig.methods` dict, defaults | `AuthConfig` | +| `_permission_name` | yaml_loader.py:80 | method, `AuthConfig.permission`, default permission | `AuthConfig` | +| `_resolved_permission` | yaml_loader.py:93 | permission name or dict | `AuthConfig` | +| `_authentication_from` | yaml_loader.py:102 | `AuthConfig` fields | `AuthConfig` | +| `_make_filtering` | yaml_loader.py:114 | `FilteringConfig` | `MetaConfig` | +| `_make_pagination` | yaml_loader.py:143 | `PaginationConfig` | `MetaConfig` | +| `_build_meta_class` | yaml_loader.py:152 | `MetaConfig`, `DefaultsConfig`, reflect flag | `MetaConfig` | +| `_resolve_methods_bases` | yaml_loader.py:206 | `MetaConfig.methods`, name registry | `MetaConfig` | +| `_build_endpoint_class` | yaml_loader.py:231 | `EndpointConfig`, `DefaultsConfig`, `YAML_FIELD_TYPES` | `EndpointConfig` | +| `load_config` | yaml_loader.py:304 | the YAML file, overrides | `LightAPIConfig` | +| `name_registry` | yaml_names.py:11 | nothing (fixed table) | `NAME_REGISTRY` constant | +| `resolve_callable` | yaml_names.py:48 | a dotted path | `LightAPIConfig` static method | +| `resolve_name` | yaml_names.py:100 | a short name or dotted path | `LightAPIConfig` static method | +| `_substitute_env` | yaml_schema.py:25 | `os.environ` | `DatabaseConfig` validator | +| `load_from_dict` | _dict_config_loader.py:11 | the config dict | `DictConfig` | +| `_route_to_class_name` | _dict_config_loader.py:150 | a route string | `DictConfig` | +| `Field` | fields.py:9 | pydantic `Field` kwargs | stays (public, `__init__.py:23`) | +| `main` | server.py:62 | `sys.argv`, `os.environ` | stays (public console script) | +| `_coerce_filter_value` | filters.py:55 | column type, query value | stays (public, `docs/api-reference/filters.md:125`) | + +### Diagnosis check + +| # | Claim | Verdict | Evidence | +|---|---|---|---| +| 1 | Dead modules `auth_strategy.py`, `auth_service.py`, `cache_handler.py`, `cache_service.py`, `middleware_pipeline.py`, `http_dispatcher.py`, `route_builder.py`, `crud_sync.py`, `crud_async.py`, `queryset.py`, `handler_factory.py`, a `PaginatorFactory`, and `constants.HttpMethod` exist | Corrected: none of them exist on this branch | `ls lightapi/.py` fails for all eleven; `git ls-files lightapi` lists 35 files, none of these; `grep PaginatorFactory` over `lightapi/`, `tests/`, `docs/` returns nothing; `constants.py` defines only `HTTPStatus` (7) and `ResponseKey` (24); `HttpMethod` lives in `methods.py:1` | +| 2 | The auth backend is picked by comparing `backend.__name__ == ...` | Corrected: it is built by `self.backend.from_config(self, login_validator)` (`config.py:95`); the only `__name__ ==` in the package is `__main__.py:5`. Confirmed that it is built per request | `from_config` at `authentication/base.py:25-35`, `basic.py:33-44`, `jwt.py:40-54`; `EndpointGuard.check` calls `authentication.build_backend` at `auth_checker.py:36` on every request that `requires_login` (35), from `EndpointHandler.handle` (`endpoint_handler.py:125`); one more build per app in `LightApi._login_backend` (`lightapi.py:160`) | +| 3 | `object.__setattr__` is used on a frozen dataclass | Corrected: no occurrence | `grep -rn "object.__setattr__" lightapi/` returns nothing. The frozen dataclasses are `Authentication`, `Filtering`, `Pagination`, `Cache` (`config.py:57, 98, 108, 128`), `AppContext` and `_Verb` (`endpoint_handler.py:26, 35`); none is mutated | +| 4 | Closed sets are handled as strings and bools | Confirmed | Full list below the table | +| 5 | `constants.HTTPStatus` duplicates the stdlib `http.HTTPStatus` | Confirmed | `constants.py:7-18` declares 11 members as `(int, Enum)`; all 11 names exist in `http.HTTPStatus` with the same values (checked with `python3 -c "import http; ..."`: 200, 201, 204, 400, 401, 403, 404, 405, 409, 422, 429). Used in `rest.py:33, 252-253, 264, 375, 426`, `endpoint_handler.py:17, 186`, `auth_checker.py:11, 40, 48`, `_login.py:15, 107, 116, 123` | +| 6 | Loose functions read `cls._meta` | Confirmed, two functions | `schema.resolve_fields` reads `cls._meta.get("serializer_normalised", ...)` at `schema.py:53`; `table_mapping._table_name` reads `endpoint_cls._meta.get("table")` at `table_mapping.py:87`. Methods also read it: `RestEndpoint._run_filter_backends` (`rest.py:330`), `RestEndpoint._list` (354), `EndpointHandler.__init__` (`endpoint_handler.py:96`), `ResponseCache._cache_cfg` (`cache_helper.py:56`), the three filters via `view._meta` (`filters.py:69, 95, 125`), `LightApi._login_backend` (`lightapi.py:154`), `LightApi._check_cache_connections` (306) | +| 7 | There are 16 `except Exception` sites | Confirmed, exactly 16 | `grep -rn "except Exception" lightapi/ \| wc -l` = 16; each listed below the table | +| 8 | `rest.py` duplicates sync and async CRUD and rebuilds the PATCH schema per request | Corrected in part | The CRUD core is written once as `_list/_retrieve/_create/_update/_destroy(session, ...)` (`rest.py:353-426`); the five sync methods wrap it in `get_sync_session` (433-456) and the five async methods call `_in_async_session`, which runs the same function through `AsyncSession.run_sync(work)` (488-511). `patch_schema` is `@lru_cache(maxsize=None)` (`schema.py:89`), so the PATCH schema is built once per create schema. Duplication that remains: `_get_queryset` (318-327) against `_get_queryset_async` (460-473), and `_get_engine` (296-316) against `_get_async_engine` (475-486); the ten one-line wrappers (433-456, 492-511) are also parallel pairs | +| 9 | `LightApi.register` is 21 lines long | Confirmed | `lightapi.py:87-107`, 21 lines including the signature and a 6-line docstring (`awk 'NR>=87 && NR<=107' \| wc -l` = 21) | +| 10 | The package uses `__import__` or in-function imports | Corrected in part: no `__import__`; `importlib.import_module` and many in-function imports exist | `grep -rn __import__ lightapi/` returns nothing. `importlib.import_module` at `yaml_names.py:57, 113` (user-supplied dotted paths) and `lightapi.py:386, 397` (probing optional async drivers). In-function imports listed below the table | + +Detail for claim 4, every literal comparison or literal-keyed structure for a closed set: + +- Execution mode (`"sync"`/`"async"` strings): `lightapi.py:98` (`self._mode == "sync"`), `99` (`self._mode = "async"`), `100`, `104`, `175`, `194`, `276` (`self._mode == "async"`), `336-337` (`return "sync"`, `mode not in ("sync", "async")`); `_dict_config_loader.py:45` (`config.get("mode", "sync")`); `yaml_schema.py:182` (`mode: str | None`). The same fact is then re-encoded as the bool `AppContext.is_async` (`lightapi.py:104`, `endpoint_handler.py:31, 94, 154, 174`) and as `SessionManager._is_async = hasattr(engine, "sync_engine")` (`session_manager.py:81, 108-109, 119`, read at `table_mapping.py:77`). +- Pagination style: `config.py:112` (default `"page_number"`), `116` (`self.style not in VALID_PAGINATION_STYLES`), `125` (`PAGINATORS[self.style]`); `constants.py:41` (`("page_number", "cursor")`); `pagination.py:194-197` (dict keyed by the two strings); `yaml_schema.py:89` (`style: str = "page_number"`). +- Reflect: `rest.py:114-115` (`reflect is True or reflect == "full" or reflect == "partial"`), `124` (`partial=reflect == "partial"`), `127` (`[] if reflect else ...`), `236` (stored raw in `_meta["reflect"]`); `yaml_schema.py:159` (`reflect: bool`); `yaml_loader.py:161` (`attrs["reflect"] = True`). +- HTTP methods: `rest.py:60` (`_ALL_METHODS` frozenset of five strings), `357, 365, 375, 402` (`"GET"`, `"GET"`, `"POST"`, `"PUT"` passed to `_serialize_row`); `schema.py:56` (`method.upper() == "GET"`); `methods.py:9, 12, 15, 18, 21` (`_http_method = "GET"` and so on); `endpoint_handler.py:67-79` (`_COLLECTION_VERBS`/`_DETAIL_VERBS` keyed by method strings), `129` (`self._verbs.get(request.method)`); `cache_helper.py:50` (`request.method == "GET"`); `_login.py:104` (`request.method != "POST"`), `108`; `authentication/basic.py:47` and `jwt.py:65` (`request.method == "OPTIONS"`); `core.py:89, 99`; `config.py:62-63` (`permission: dict[str, type]` keyed by method string, read by `permission_for` at 75); `auth_checker.py:35, 44` (pass `request.method` through); `yaml_names.py:40-44` and `_dict_config_loader.py:125-131` (two copies of the name-to-mixin map), `_dict_config_loader.py:133` (`method.upper()`); `lightapi.py:145` (`methods=["POST"]`). +- `is_async` bool: see the execution-mode entry (`endpoint_handler.py:31, 94, 154, 174`; `session_manager.py:81, 108-109, 119`; `table_mapping.py:77`). +- Rate-limit windows (`"minute"`/`"hour"`/`"day"`): `rate_limiter.py:73, 75, 77` (`window_name == ...` in `_get_window_seconds`), `117-121` (tuple list of window name and limit), `125` (`f"{endpoint}:{window_name}"` key), `135` (returns the window name string), `145` (default `window: str = "minute"`), `149, 153` (`window == "hour"`, `window == "day"` in `get_rate_limit_response`); the string travels through `LoginEndpoint.handle` (`_login.py:98-102`). +- Also string-typed: the deprecated dict loader's auth kind, `auth == "jwt"` / `auth == "basic"` (`_dict_config_loader.py:88, 93`). + +Detail for claim 7, the 16 `except Exception` sites and what each one protects: + +1. `cache.py:29`, `RedisCacheBackend._get_client`: `redis.from_url` failing; returns `None` so every later call is a no-op. +2. `cache.py:40`, `RedisCacheBackend.get`: `client.get` or `json.loads` failing; returns `None` (cache miss). +3. `cache.py:50`, `RedisCacheBackend.set`: `client.setex` failing; returns `False`. +4. `cache.py:60`, `RedisCacheBackend.delete`: `client.delete` failing; returns `False`. +5. `cache.py:72`, `RedisCacheBackend.invalidate_prefix`: `scan_iter` or `delete` failing; returns `False`. +6. `cache.py:81`, `RedisCacheBackend.ping`: `client.ping` failing; returns `False` (drives the startup `RuntimeWarning` at `lightapi.py:310-316`). +7. `cache_helper.py:62`, `ResponseCache._cached_response`: `get_cached` raising; treated as a miss. +8. `cache_helper.py:74`, `ResponseCache._store_response`: body decode, `json.loads` or `set_cached` raising; the response is served uncached. +9. `pagination.py:158`, `CursorPaginator.paginate`: a malformed `?cursor=` (base64 or JSON error) or a select without an `id` column; the cursor is ignored and the first page is returned. +10. `lightapi.py:300`, `LightApi._create_tables`: any error from `metadata.create_all` or the async equivalent; logged as a warning and startup continues. +11. `filters.py:50`, `BaseFilter._coerce_filter_value`: `int()`/`float()` on a non-numeric value, or a column object without `.property`/`.type`; the raw string is used. +12. `rest.py:149`, `_field_annotations`: `typing.get_type_hints(cls)` failing on an unresolvable forward reference; falls back to the raw `__annotations__`. +13. `session.py:31`, `get_sync_session`: any error inside the `with` block; rolls back and re-raises (not swallowed). +14. `session.py:45`, `get_async_session`: same for `AsyncSession`; rolls back and re-raises. +15. `_login.py:137`, `LoginEndpoint._validated_user`: the user's `login_validator` or `validate_credentials` raising; logged as a warning and turned into a 401. +16. `endpoint_handler.py:196`, `_read_body`: `request.body()` or `json.loads` failing; the body becomes `{}`. + +Detail for claim 10, every import inside a function body (file:line, imported name, reason): + +- `config.py:72` and `84`, `from lightapi.authentication import AllowAny` inside `permission_for` and `requires_login`: cycle (`authentication/jwt.py:13` imports `config` at module level). +- `schema.py:29`, `from lightapi.config import Serializer` inside `normalise_serializer`: none today; `config.py` imports `constants`, `exceptions`, `pagination` only, so a top-level import would not cycle. +- `rest.py:71-72`, `import types`, `import typing` inside `_is_optional`; `rest.py:143`, `import typing` inside `_field_annotations`: none (stdlib). +- `rest.py:161`, `from pydantic.fields import FieldInfo` inside `_declared_columns`; `rest.py:222`, `from pydantic import BaseModel` inside `_parsed_meta`: none (pydantic is already imported at `rest.py:17`). +- `rest.py:310`, `from sqlalchemy.ext.asyncio import AsyncEngine` inside `_get_engine`: optional dependency (`sqlalchemy[asyncio]` is in the `async` extra, `pyproject.toml:50-51`); guarded by `except ImportError`. +- `session.py:39`, `from sqlalchemy.ext.asyncio import AsyncSession` inside `get_async_session`: optional dependency. +- `lightapi.py:255`, `from lightapi._dict_config_loader import load_from_dict` inside `from_dict`: cycle (`_dict_config_loader.py:40` does `from lightapi import RestEndpoint`, which needs the package root, which imports `lightapi.lightapi`), and the loader is deprecated. +- `lightapi.py:288`, `import concurrent.futures` inside `_create_tables`: none (stdlib). +- `lightapi.py:308`, `from lightapi.cache import _ping_redis` inside `_check_cache_connections`: none (`redis` is a required dependency, `pyproject.toml:42`, and `lightapi/__init__.py:12` already imports `lightapi.cache`). +- `lightapi.py:371`, `from sqlalchemy.ext.asyncio import AsyncEngine` inside `_require_async_engine`: optional dependency, guarded by `except ImportError`. +- `filters.py:36`, `from sqlalchemy import Boolean, Float, Integer, Numeric` inside `_coerce_filter_value`; `filters.py:100`, `from sqlalchemy import or_` inside `SearchFilter.filter_queryset`: none (`sqlalchemy` is imported at `filters.py:5`). +- `yaml_names.py:12-21`, `from lightapi.auth import ...`, `from lightapi.core import ...`, `from lightapi.filters import ...`, `from lightapi.methods import HttpMethod` inside `name_registry`: none; none of those modules imports `yaml_names` or `yaml_loader`. `yaml_names.py:67`, `import inspect` inside `resolve_callable`: none (stdlib). +- `yaml_loader.py:103, 118, 147, 185, 191`, `from lightapi.config import Authentication / Filtering / Pagination / Cache / Serializer`; `119`, `from lightapi.filters import ...`; `208`, `from lightapi.rest import RestEndpoint`; `233`, `from typing import Optional`; `235`, `from lightapi.fields import Field`; `312`, `import yaml`; `313`, `from pydantic import ValidationError`: none. `config`, `filters`, `rest` and `fields` do not import `yaml_loader`; `PyYAML` is a required dependency (`pyproject.toml:43`). +- `_dict_config_loader.py:40`, `from lightapi import RestEndpoint`: cycle with the package root (see `lightapi.py:255`). `_dict_config_loader.py:41, 94`, `from lightapi.auth import ...`; `42, 108`, `from lightapi.config import ...`; `123`, `from lightapi.methods import HttpMethod`: none. + +## After the refactor + +State of `lightapi/` at the end of the second OO pass (branch `refactor/oo-second-pass`). +30 modules (was 35), 81 classes, 10 enums, and 6 module-level functions, every one of them +public or documented: `Field`, `get_sync_session`, `get_async_session`, `server.main`, +`filters._coerce_filter_value`, `cache.invalidate_cache_prefix`. + +### Module overview + +```mermaid +flowchart LR + subgraph app["Application"] + LA[lightapi.py: LightApi] + EH[endpoint_handler.py: EndpointHandler, EndpointGuard, MiddlewareChain] + LOGIN[_login.py: LoginRequest, LoginEndpoint] + HEALTH[health.py] + SERVER[server.py: ServerCommand, main] + end + subgraph core["Core"] + REST[rest.py: RestEndpointMeta, RestEndpoint] + REPO[repository.py: Repository] + SCHEMA[schema.py: SchemaFactory, FieldProjection] + TABLE[table_mapping.py: TableSource, DeclaredTable, ReflectedTable] + SM[session_manager.py: SessionManager] + SESS[session.py: get_sync_session, get_async_session] + end + subgraph cfg["Configuration"] + CONFIG[config.py: Config, Authentication, Filtering, Pagination, Cache, Serializer] + CONST[constants.py: HttpVerb, ExecutionMode, TableReflection, JwtAlgorithm, ApiError, ResponseKey] + YAML[yaml_config.py: LightAPIConfig and the YAML models] + DICT[_dict_config_loader.py: DictConfig, DictAuthKind] + end + subgraph features["Features"] + AUTH[authentication/: backends and permissions] + CACHE[cache.py: RedisCacheBackend, EndpointCache] + PAG[pagination.py: PaginationStyle, Cursor, paginators] + FILT[filters.py: BaseFilter and backends] + RL[rate_limiter.py: RateLimiter, RateLimitWindow] + end + LA --> EH --> REST --> REPO + REST --> SCHEMA + REST --> TABLE --> SCHEMA + REST --> SESS + EH --> CACHE + EH --> SM + LA --> LOGIN --> RL + LA --> YAML --> CONFIG + LA --> DICT --> CONFIG + CONFIG --> PAG + REST --> CONFIG + AUTH --> CONFIG + EH --> AUTH + FILT --> REST + CONST --> AUTH + CONST --> REST + CONST --> EH + CONST --> CACHE + SM -.->|engine, run_blocking| REPO + CACHE -.->|RedisCacheBackend.shared| REDIS[(Redis)] + SM -.-> DB[(SQLAlchemy engine)] + AUTH -.-> JWT[PyJWT] + YAML -.-> PYYAML[PyYAML] +``` + +Global state left: `config.config` (the `Config` instance `JWTAuthentication` reads, kept as +documented), `session_manager._state` (one `_GlobalState` with `reset()`), and +`RedisCacheBackend.shared()` (a `functools.cache` on a classmethod, built on first use). + +### Enums (constants.py, pagination.py, rate_limiter.py, _dict_config_loader.py) + +```mermaid +classDiagram + class HttpVerb { + <> + GET POST PUT PATCH DELETE OPTIONS + +of(method: str) HttpVerb or None + } + class ExecutionMode { + <> + SYNC ASYNC + +from_user(mode: str or None) ExecutionMode + } + class TableReflection { + <> + FULL PARTIAL + +from_meta(value: object) TableReflection or None + } + class JwtAlgorithm { + <> + HS256 HS384 HS512 RS256 RS384 RS512 ES256 ES384 ES512 + +validated(name: str) str + } + class ApiError { + <> + NOT_FOUND VERSION_CONFLICT CONSTRAINT_VIOLATION INSUFFICIENT_PERMISSIONS INVALID_CREDENTIALS + +status: HTTPStatus + +detail: str + +headers: dict or None + +response() JSONResponse + +validation_failed(errors) JSONResponse + } + class ResponseKey { + <> + DETAIL RESULTS TOKEN USER COUNT PAGES NEXT PREVIOUS + } + class PaginationStyle { + <> + PAGE_NUMBER CURSOR + +build(page_size: int) Paginator + } + class RateLimitWindow { + <> + MINUTE HOUR DAY + +seconds int + +from_key(key: str) RateLimitWindow + } + class DictAuthKind { + <> + JWT BASIC + +backend type + } + class _CrudArgument { + <> + REQUEST DATA PK FULL_UPDATE PARTIAL_UPDATE + } +``` + +`http.HTTPStatus` from the standard library replaced the copy in `constants.py`. Members of +a `(str, Enum)` are never formatted (`f"{member}"` differs between Python 3.10 and 3.11+); +code that needs the text uses `.value`. + +### Core + +```mermaid +classDiagram + class RestEndpointMeta { + +__new__(name, bases, namespace) type + -_process(cls, name, namespace) None + -_reject_redeclared_auto_fields(name, namespace) None + -_parsed_meta(name, meta_obj) dict + -_methods_from_mixins(cls) set of str + } + class RestEndpoint { + +_meta: dict + +_allowed_methods: set of str + +_model_class: type or None + +_table_source: TableSource + +background(fn, *args, **kwargs) None + +list(request) Response + +retrieve(request, pk) Response + +create(data) Response + +update(data, pk, partial) Response + +destroy(request, pk) Response + +_list_async(request) Response + +_retrieve_async(request, pk) Response + +_create_async(data) Response + +_update_async(data, pk, partial) Response + +_destroy_async(request, pk) Response + -_session_manager_or_raise() SessionManager + -_get_engine() Engine + -_get_async_engine() AsyncEngine + -_get_queryset(request) Select + -_get_queryset_async(request) Select + -_fields_for(verb: HttpVerb) list or None + -_serialize_row(row, fields) dict + -_list(session, request, qs) Response + -_retrieve(session, pk) Response + -_create(session, data) Response + -_update(session, data, pk, partial) Response + -_update_values(data, partial, nullable_columns) dict + -_destroy(session, pk) Response + -_repository(session) Repository + } + class Repository { + -_MIN_PK: int + -_MAX_PK: int + +nullable_columns set of str + +get(pk) row + +add(values) row + +update(pk, expected_version, values) row + +delete(pk) None + -_in_pk_range(pk) bool + -_utcnow() datetime + } + class RowNotFound + class VersionConflict + class FieldProjection { + <> + +fields: list or None + +read: list or None + +write: list or None + +from_serializer(serializer) FieldProjection + +for_method(method: str) list or None + +apply(d: dict, fields) dict + } + class SchemaFactory { + +build(cls) tuple + +build_from_reflected_table(cls, table) tuple + +row_to_dict(row) dict + +patch_schema(schema_create) type + -_schema_pair(name, create_fields, read_fields) tuple + -_reflected_annotation(column) type + } + class TableSource { + <> + +map(endpoint_cls, session_manager)* None + -_table_name(endpoint_cls, session_manager) str + -_strip_field_infos(endpoint_cls, columns) None + } + class DeclaredTable { + +from_endpoint(endpoint_cls, name, namespace) DeclaredTable + +map(endpoint_cls, session_manager) None + -_field_annotations(endpoint_cls) dict + -_column_for(endpoint_name, field_name, annotation, field_info) Column or None + -_is_optional(annotation) tuple + -_auto_columns() list of Column + } + class ReflectedTable { + -_reflection: TableReflection + +map(endpoint_cls, session_manager) None + -_reflect(table_name, session_manager) Table + -_reflect_sync(engine, metadata, table_name) Table + -_reflect_async(engine, metadata, table_name) Table + } + class SessionManager { + +engine + +sync_engine + +metadata: MetaData + +registry + +is_async: bool + +run_blocking(fn, *args) T + +table_name_for(base_name) str + +create_tables_now() None + } + RestEndpointMeta ..> DeclaredTable : from_endpoint + RestEndpointMeta ..> ReflectedTable + RestEndpointMeta ..> SchemaFactory : build + RestEndpointMeta ..> FieldProjection : from_serializer + TableSource <|-- DeclaredTable + TableSource <|-- ReflectedTable + ReflectedTable ..> SchemaFactory : build_from_reflected_table + RestEndpoint ..> Repository + RestEndpoint ..> FieldProjection : for_method, apply + RestEndpoint ..> SchemaFactory : row_to_dict, patch_schema + RestEndpoint ..> SessionManager : _session_manager_or_raise + Repository ..> RowNotFound + Repository ..> VersionConflict +``` + +### Request path + +```mermaid +classDiagram + class LightApi { + -_LOGIN_RATE_LIMITS: dict + -_ASYNC_HANDLER_NAMES: tuple + -_mode: ExecutionMode + +register(mapping) None + +build_app(debug) Starlette + +run(host, port, debug, reload) None + +from_config(config_path, **kwargs) LightApi + +from_dict(config, **kwargs) LightApi + -_register_endpoint(path, cls, app_context) None + -_login_routes() list of Route + -_login_backend() BaseAuthentication or None + -_asgi_routes() list of Route + -_create_tables() None + -_create_tables_inside_running_loop(metadata) None + -_check_cache_connections() None + -_engine_from_url(database_url) Engine + -_as_rate_limiter(value) RateLimiter or None + -_require_rest_endpoint(path, cls) None + -_has_async_handlers(endpoint_cls) bool + -_require_async_engine(engine) None + -_validate_async_dependencies(engine) None + } + class AppContext { + <> + +middlewares: MiddlewareChain + +mode: ExecutionMode + +login_validator + } + class MiddlewareChain { + <> + +classes: list of type + +before(request) Response or None + +after(request, response) Response + } + class EndpointGuard { + +backend BaseAuthentication + +check(request) Response or None + } + class _Verb { + <> + +override: str + +sync_name: str + +async_name: str + +arguments: tuple of _CrudArgument + +reads_body: bool + +cached: bool + +call_arguments(request, data, pk) tuple + } + class EndpointHandler { + +for_collection(endpoint_cls, app) EndpointHandler + +for_detail(endpoint_cls, app) EndpointHandler + +methods list of str + +handle(request) Response + -_serve(verb, endpoint, request) Response + -_call_override(override, endpoint, request) Response + -_json_body(request) dict + -_method_not_allowed() Response + } + class EndpointCache { + +serve(request, fn) Response + +serve_async(request, fn) Response + +invalidate_after_write(request) None + -_cached_response(request) Response or None + -_store_response(request, response) None + -_key(request) str + -_key_prefix() str + } + class RedisCacheBackend { + +shared() RedisCacheBackend + +get(key) Any or None + +set(key, value, timeout) bool + +delete(key) bool + +invalidate_prefix(prefix) bool + +ping() bool + } + LightApi ..> AppContext : one per register() + LightApi ..> EndpointHandler : two per endpoint + LightApi ..> RedisCacheBackend : ping at build_app + EndpointHandler --> MiddlewareChain + EndpointHandler --> EndpointGuard : when Meta.authentication + EndpointHandler --> EndpointCache : one per route + EndpointHandler ..> _Verb + EndpointGuard ..> ApiError + EndpointCache ..> RedisCacheBackend : shared() +``` + +```mermaid +sequenceDiagram + participant S as Starlette Route + participant H as EndpointHandler + participant M as MiddlewareChain + participant G as EndpointGuard + participant E as RestEndpoint + participant C as EndpointCache + participant R as Repository + S->>H: handle(request) + H->>M: before(request) + H->>G: check(request) + Note over G: backend built on the first call, cached + H->>H: HttpVerb.of(request.method), verb table + H->>H: _json_body(request) when the verb reads a body + alt ExecutionMode.ASYNC + H->>C: serve_async(request, crud) for cached verbs + C->>E: _list_async / _retrieve_async / ... + else ExecutionMode.SYNC + H->>E: SessionManager.run_blocking(cache.serve or crud) + end + E->>E: _fields_for(verb) once per request + E->>R: get / add / update / delete + E-->>H: Response or dict + H->>C: invalidate_after_write(request) + H->>M: after(request, response) +``` + +Built once at `register()`: handlers, guards, caches, routes, table mapping, schemas, +paginators (`Pagination.paginator`). Per request: the endpoint instance, middleware +instances (as `Middleware` subclasses always were), the body parse, `Repository` and the +session. + +### Authentication, configuration and login + +```mermaid +classDiagram + class Authentication { + <> + +backend: type or None + +permission: type or dict or None + +jwt_expiration +jwt_extra_claims +jwt_algorithm +rate_limiter + +permission_for(method) type or None + +requires_login(method) bool + +build_backend(login_validator) BaseAuthentication + } + class BasePermission { + +requires_authentication: bool = True + +has_permission(request) bool + } + class AllowAny { + +requires_authentication = False + } + class IsAuthenticated + class IsAdminUser + class BaseAuthentication { + +from_config(authentication, login_validator) BaseAuthentication + +authenticate(request) bool + +validate_credentials(username, password) dict or None + } + class JWTAuthentication + class BasicAuthentication + class LoginRequest { + <> + +username: str + +password: str + +from_request(request) LoginRequest or None + +parse_basic_header(header) tuple or None + } + class LoginEndpoint { + +handle(request) JSONResponse + -_validated_user(username, password) dict or None + } + class Config { + +jwt_secret +jwt_algorithm +jwt_expiration + +jwt_secret_value str or None + +jwt_algorithm_value str + } + class Pagination { + <> + +style: PaginationStyle + +page_size: int + +paginator Paginator + } + BasePermission <|-- AllowAny + BasePermission <|-- IsAuthenticated + BasePermission <|-- IsAdminUser + BaseAuthentication <|-- JWTAuthentication + BaseAuthentication <|-- BasicAuthentication + Authentication ..> BasePermission : requires_authentication + Authentication ..> BaseAuthentication : from_config + LoginEndpoint ..> LoginRequest + LoginEndpoint ..> ApiError + BasicAuthentication ..> LoginRequest : parse_basic_header + JWTAuthentication ..> Config : config.jwt_secret_value + Config ..> JwtAlgorithm : validated + Pagination ..> PaginationStyle : build +``` + +`config.py` no longer imports `lightapi.authentication` at runtime: the permission says +whether login is required, so the import cycle that forced two in-function imports is gone. + +### YAML and dict configuration + +```mermaid +classDiagram + class LightAPIConfig { + <> + +database +cors_origins +defaults +endpoints +middleware +auth +mode + +from_file(config_path) LightAPIConfig + +build_app(app_cls, **overrides) LightApi + +resolve_name(name) type + +resolve_callable(dotted_path) callable + } + class EndpointConfig { + +route +fields +reflect +meta + +class_name str + +build_endpoint_class(defaults) type + } + class MetaConfig { + +methods +authentication +filtering +pagination +cache +serializer +table + +build_meta_class(defaults, reflect) type + +endpoint_bases() tuple of type + } + class FieldSpec { + +type +optional + +annotation type + +field_info() FieldInfo or None + } + class AuthConfig { + +merged_over(defaults) AuthConfig + +resolved_permission() type or dict or None + +to_authentication(permission) Authentication + } + class FilteringConfig { + +to_filtering() Filtering + } + class PaginationConfig { + +to_pagination() Pagination + } + class DictConfig { + <> + +config: dict + +build(app_cls, **overrides) LightApi + -_endpoint_class(route, spec) type + -_meta_attributes(spec) dict + -_method_mixins(methods) list of type + -_class_name(route) str + } + LightAPIConfig *-- EndpointConfig + EndpointConfig *-- MetaConfig + EndpointConfig *-- FieldSpec + MetaConfig *-- AuthConfig + MetaConfig *-- FilteringConfig + MetaConfig *-- PaginationConfig + DictConfig ..> DictAuthKind +``` + +The name registry (`NAME_REGISTRY`) and the verb-to-mixin map (`HTTP_METHOD_MIXINS`) are +module constants built with top-level imports; none of the modules they name imports +`yaml_config` or `lightapi.lightapi`, so there is no cycle. + +### Pagination and filters + +```mermaid +classDiagram + class Paginator { + <> + +render(request, qs, session, serialize) dict + } + class Cursor { + <> + +last_id: int + +INVALID: tuple of exception types + +encode() str + +decode(text) Cursor + } + class NoPagination + class PageNumberPaginator { + +render(...) dict + +paginate(request, qs, session, page_size) tuple + +paginate_async(...) tuple + +wrap(request, results, total, page, page_size) dict + -_current_page(request) int + } + class CursorPaginator { + +render(...) dict + +paginate(request, qs, session, page_size) tuple + +paginate_async(...) tuple + +wrap(results, next_cursor, prev_cursor) dict + } + class BaseFilter { + +RESERVED_PARAMS + +filter_queryset(request, queryset, view) Select + -_escape_like(value) str + -_coerce_filter_value(col, value) Any + } + class FieldFilter + class SearchFilter + class OrderingFilter + Paginator <|.. NoPagination + Paginator <|.. PageNumberPaginator + Paginator <|.. CursorPaginator + CursorPaginator ..> Cursor + BaseFilter <|-- FieldFilter + BaseFilter <|-- SearchFilter + BaseFilter <|-- OrderingFilter +``` + +### What changed against the "before" diagnosis + +- No module-level function outside the six public or documented ones listed at the top. +- Every closed set is an enum (`HttpVerb`, `ExecutionMode`, `TableReflection`, + `PaginationStyle`, `JwtAlgorithm`, `RateLimitWindow`, `ApiError`, `DictAuthKind`, + `_CrudArgument`), converted at the edge; the strings users pass still work. +- `except Exception` remains only where it is the contract: `_login.py` (a + `login_validator` that raises is a 401, CHANGELOG 0.1.24) and the two rollback-and-reraise + guards in `session.py`. +- No import inside a function except the two optional-extra (`sqlalchemy[asyncio]`) probes. +- Built once per route instead of per request: the auth backend, the response cache object, + the paginator; the serializer projection is resolved once per request instead of per row. +- Modules removed: `auth_checker.py`, `middleware_runner.py`, `cache_helper.py`, + `yaml_loader.py`, `yaml_names.py`, `yaml_schema.py` (the last three became + `yaml_config.py`). diff --git a/docs/examples/yaml-configuration.md b/docs/examples/yaml-configuration.md index 4704404..59435ef 100644 --- a/docs/examples/yaml-configuration.md +++ b/docs/examples/yaml-configuration.md @@ -241,7 +241,7 @@ Every field in `fields:` requires a `type` key. All other keys are forwarded as | `unique` | any | Adds a UNIQUE constraint to the database column. | | `index` | any | Adds an index to the database column. | | `foreign_key` | `int` (usually) | SQLAlchemy foreign key (e.g. `"users.id"`). | -| `decimal_places`| `Decimal` | Number of decimal places for `Decimal` columns. | +| `decimal_places`| `Decimal` | A value with more decimal places is rejected (422). | ### Complete field example @@ -706,7 +706,7 @@ Any exception raised inside `login_validator` — regardless of type — causes | `endpoints[].fields..unique` | bool | `false` | Adds a UNIQUE database constraint. | | `endpoints[].fields..index` | bool | `false` | Adds a database index. | | `endpoints[].fields..foreign_key` | string | — | Foreign key reference (e.g. `"users.id"`). | -| `endpoints[].fields..decimal_places` | int | — | Decimal precision. `Decimal` only. | +| `endpoints[].fields..decimal_places` | int | — | More decimal places than this is a 422. `Decimal` only. | | `endpoints[].meta.methods` | list[string] or dict | `[]` | HTTP verbs as list, or dict of `{VERB: {authentication: ...}}`. | | `endpoints[].meta.authentication.backend` | string | — | Auth backend override for this endpoint. | | `endpoints[].meta.authentication.permission` | string | — | Permission class override for this endpoint. | diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index a156bc8..f3c362d 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -281,7 +281,7 @@ Each key under `endpoints[].fields` maps a column name to: | `index` | bool | Create a database index. | | `unique` | bool | Add a UNIQUE constraint. | | `foreign_key` | string | `"table.col"` — adds a SQLAlchemy `ForeignKey` constraint. | -| `decimal_places` | int | Precision for `Decimal` columns. | +| `decimal_places` | int | A `Decimal` with more than N decimal places is rejected (422); the column type stays `NUMERIC`. | ### Cache invalidation diff --git a/docs/refactor-log.md b/docs/refactor-log.md new file mode 100644 index 0000000..43e64bf --- /dev/null +++ b/docs/refactor-log.md @@ -0,0 +1,341 @@ +# Refactor log: second OO pass (branch `refactor/oo-second-pass`) + +Working notes for the refactor described in the plan of 2026-10-10. One entry per step: +what was done, how it was verified, what comes next. If the work is interrupted, resume +from the last "Next" line. + +Base: `origin/test/release-validation` (PR #44) with `origin/refactor/group-loose-symbols` +(PR #45) and `origin/fix/edge-case-500s` (PR #46) merged in. The only merge conflict was the +`lightapi.constants` import line in `lightapi/rest.py`: kept the `ResponseKey` import from +#45 and the `TypeAdapter` import from #46. + +## Phase 0: baseline (merged base, commit d24c7f3) + +Commands (run from the repo root; `--frozen` keeps `uv.lock` untouched): + +``` +uv run --frozen --extra dev pytest tests/ -q +uv run --isolated --frozen --python 3.10 --extra dev pytest tests/ -q --cov=lightapi +uv run --frozen --extra dev ruff check lightapi +uv run --frozen --extra dev ruff format --check lightapi +uv run --frozen --extra dev mypy lightapi +uv build && uvx twine check dist/* +``` + +| Measure | Baseline | +|---|---| +| pytest, Python 3.13 (local `.venv`) | 547 passed, 19 skipped, 1 xfailed, 2 xpassed, 656 warnings | +| pytest, Python 3.10 (`--isolated`) | 547 passed, 19 skipped, 1 xfailed, 2 xpassed, 657 warnings | +| coverage (`--cov=lightapi`, 3.10) | 90% (2136 statements, 213 missed) | +| ruff check / ruff format --check | clean / 35 files formatted | +| mypy lightapi | 0 errors (35 files; `[tool.mypy]` has `ignore_errors` for 18 modules) | +| `uv build` + `twine check` | wheel + sdist built (0.1.27 on this branch; `master` is 0.1.29), both PASSED | +| `lightapi/` size | 35 files, 4647 lines, 69 classes, 2 enums, 72 module-level functions | + +The 19 skipped tests are the Postgres/Redis integration tests (`tests/integration/`), which +need `LIGHTAPI_TEST_PG_SYNC`, `LIGHTAPI_TEST_PG_ASYNC` and `LIGHTAPI_REDIS_URL`. + +Module-level functions at baseline, by file: `yaml_loader.py` 11, `rest.py` 10, +`schema.py` 7, `lightapi.py` 7, `endpoint_handler.py` 6, `cache.py` 5, `table_mapping.py` 4, +`_login.py` 3, `pagination.py` 3, `session.py` 3, `yaml_names.py` 3, `_dict_config_loader.py` +2, `middleware_runner.py` 2, `repository.py` 2, `fields.py` 1, `filters.py` 1, `server.py` 1, +`yaml_schema.py` 1. Of these, `Field`, `get_sync_session`, `get_async_session`, +`server.main`, `filters._coerce_filter_value` and `cache.invalidate_cache_prefix` are public +or documented and stay. + +### HTTP snapshot (Phase 0 step 4) + +`examples/18_full_api.py` served by a real uvicorn on 127.0.0.1:18080 (port 8000 was taken +by Docker; the launcher reproduces the example's `__main__` block on another port). 41 +requests recorded (status, `content-type`/`allow`/`www-authenticate` headers, raw body with +timestamps and JWTs normalised): `/healthz`, list, create, get, PUT, PATCH, DELETE, 404 (missing +and oversized id), 409 (PUT and PATCH), 422 (missing version, bad version type, empty body, +malformed JSON on POST and PUT), 401 (no token, bad token, bad login), 403, 405 (HEAD on a +collection, PUT on a GET/POST endpoint, POST on a GET-only endpoint, GET on `/auth/login`), +cursor page with a bad cursor, `?page=abc`, filters. Recorded twice: identical. Kept outside +the repo (session scratchpad, `http-before/run.json`) and diffed again in Phase 3. + +Differential harness: wheel of this base installed in its own venv, `tools/differential/ +record.py` -> 412 steps (`run-base.json`, source fingerprint f11296220501). Phase 3 compares +the final wheel against it with `compare.py`. + +## Step 1: characterization tests + +`tests/test_second_pass_characterization.py`, 16 tests, all green before any code change: +`WWW-Authenticate: Bearer` on the 401 of JWT and Basic endpoints; a method missing from a +permission dict is public; `/auth/login` GET is 405 with `Allow: POST`; `mode="sync"` +accepted; exact messages for an unknown mode, pagination style and JWT algorithm; +`reflect = "full"` behaves like `True`; malformed JSON on POST and PUT gives the current 422 +shapes; an invalid cursor is ignored; HEAD on a detail route is 405 with `Allow`; async PATCH +409 and 422; cached GET and POST still answer when the Redis client raises `ConnectionError`. + +## Step 2: leftover dead code + +`rest.py` only: dropped the never-read `_meta["reflect"]` key, moved the stdlib and +pydantic imports that lived inside `_is_optional`, `_field_annotations`, `_declared_columns` +and `_parsed_meta` to the module top (no cycle existed), and with them the +`# type: ignore[attr-defined]` on `types.UnionType`. 563 passed, ruff and mypy clean. + +## Step 3: enums and the standard library (one commit each) + +### 3a. `http.HTTPStatus` + +Deleted the `HTTPStatus(int, Enum)` copy in `constants.py`; `rest.py`, `endpoint_handler.py`, +`auth_checker.py` and `_login.py` import the stdlib enum. The raw status integers in +`authentication/basic.py` (401), `core.py` (200, 403), `rate_limiter.py` (429) and +`cache_helper.py` (`== 200`) became members too. `Response.__init__(status_code: int = 200)` +in `core.py` is a public signature and stays. Starlette and h11 take the `IntEnum` as an int, +so the bytes on the wire are the same. 563 passed, ruff and mypy clean. + +### 3b. `HttpVerb` + +`constants.HttpVerb(str, Enum)` (GET, POST, PUT, PATCH, DELETE, OPTIONS) with `HttpVerb.of()` +for request methods the API never serves (HEAD), and `CRUD_VERBS` replacing +`rest._ALL_METHODS`. Verb dictionaries in `endpoint_handler.py` are keyed by the enum; the +`request.method == "..."` comparisons in `cache_helper.py`, `_login.py`, `core.py` and +`authentication/basic.py` compare against members; `CORSMiddleware` keeps its default list as +a class constant. `RestEndpoint._allowed_methods` stays a set of plain strings (documented +that way) and `HttpMethod.GET._http_method` stays `"GET"` (public marker): the metaclass +validates them through `HttpVerb(...)` and keeps `.value`. Members are never formatted +(`f"{member}"` differs between Python 3.10 and 3.11+). `jwt.py:65` keeps its literal +(FROZEN). Left for Step 8: the name registries in `yaml_names.py` and `_dict_config_loader.py` +that map YAML/dict method names to `HttpMethod` mixins. 563 passed, ruff and mypy clean. + +### 3c. `ExecutionMode` + +`constants.ExecutionMode(str, Enum)` (SYNC, ASYNC) with `from_user()` keeping the exact +`mode must be 'sync' or 'async', got '...'` text. `LightApi._mode` holds the member; +`AppContext.mode` replaces `is_async: bool`; `EndpointHandler` compares +`self._mode is ExecutionMode.ASYNC` in its two branches (no generic sync/async executor, as +the prompt asks). `_checked_mode` is gone. The deprecated `_dict_config_loader.py` still reads +`config.get("mode", "sync")`; it is reworked in Step 8. + +### 3d. `PaginationStyle` + +`pagination.PaginationStyle(str, Enum)` (PAGE_NUMBER, CURSOR) with `build(page_size)` +replaces `VALID_PAGINATION_STYLES` and the `PAGINATORS` dict. `Pagination.style` is +normalised to the member in `__post_init__` (frozen dataclass idiom, `object.__setattr__` in +that one place) with the same error text, and `Pagination.paginator` is a `cached_property`: +the paginator is built once per configuration instead of once per list request +(`rest.py` uses a module `_NO_PAGINATION` when no pagination is configured). The YAML +`PaginationConfig` defaults come from the enum and `DEFAULT_PAGE_SIZE`. + +Trap hit twice in 3b-3d: the user-level PostToolUse hook runs `ruff --fix` after every edit +and strips an import whose use is not in the file yet. Order of work that survives it: edit +the usages first (ruff reports F821, changes nothing), then the import line. + +### 3e. `TableReflection` + +`constants.TableReflection(str, Enum)` (FULL, PARTIAL) with `from_meta()`: `True` -> FULL, +`False`/`None` -> no reflection, the two strings -> their member, anything else -> +`ConfigurationError` (decision 1 of the plan; before, an unknown truthy value silently built a +table with only the automatic columns). `ReflectedTable` takes the member instead of a +`partial: bool`. One regression test added to the characterization file; CHANGELOG `Changed`. + +### 3f. `RateLimitWindow` + +`rate_limiter.RateLimitWindow(str, Enum)` (MINUTE, HOUR, DAY) carries `seconds`; +`RateLimiter._limit_for(window)` replaces the two if-chains and the tuple list. Store keys and +the window name returned by `is_rate_limited` use `.value` (plain strings, identical on every +Python). `get_rate_limit_response(window: str)` keeps its public signature and still answers an +unknown name as the minute window. + +### 3g. `JwtAlgorithm` + +`constants.JwtAlgorithm(str, Enum)` with the nine names and `validated(name) -> str` +replaces `VALID_JWT_ALGORITHMS`; `Config.jwt_algorithm_value` delegates to it and still returns +the plain string PyJWT receives (`jwt.py` is FROZEN). Same error text. + +### 3h. `ApiError` + +`constants.ApiError(Enum)` holds the fixed error replies (status, detail, extra headers): +`NOT_FOUND`, `VERSION_CONFLICT`, `AUTHENTICATION_REQUIRED` (with `WWW-Authenticate: Bearer`), +`INSUFFICIENT_PERMISSIONS`, `INVALID_CREDENTIALS`, `LOGIN_METHOD_NOT_ALLOWED` (with +`Allow: POST`), plus `validation_failed(errors)` for the 422 shape. `rest.py`, `_login.py` and +`auth_checker.py` build their error responses through it; the dynamic 405 of +`EndpointHandler` (lists the allowed verbs) stays where it is. Texts, status codes and headers +are byte-identical (characterization tests and the HTTP snapshot pin them). + +## Step 4: authentication + +- Cycle `config.py` <-> `authentication/` broken by Tell-Don't-Ask: + `BasePermission.requires_authentication: ClassVar[bool] = True`, + `AllowAny.requires_authentication = False`. `Authentication.permission_for` returns the + configured class or `None`; `requires_login` asks the permission. `config.py` no longer + imports `lightapi.authentication` at runtime (the two in-function imports are gone). + Visible only to a subclass of `AllowAny` used as a permission (decision 2 of the plan): + CHANGELOG `Changed`, regression test added. +- `EndpointGuard` now exists only for endpoints with an `Authentication` (`EndpointHandler` + keeps `None` otherwise) and builds the backend once, lazily, as a `cached_property`: eager + construction would break `tests/test_login_auth.py:857` (`backend=AllowAny`, no + `from_config`, never requested). Regression test counts `from_config` calls across two + requests. CHANGELOG `Changed`. +- `_login.py`: `_parse_basic_header`, `_parse_credentials` and `_read_body` became + `LoginRequest.parse_basic_header` (also used by `BasicAuthentication`) and + `LoginRequest.from_request` (Basic header -> `model_construct`, unvalidated as before; JSON + body -> `request.json()` validated). The body read now catches `JSONDecodeError` and + `UnicodeDecodeError` (before: `JSONDecodeError, TypeError`), so a login body that is not + UTF-8 is a 422 instead of a 500: CHANGELOG `Changed`, regression test added. +- `_login.py:137` keeps `except Exception` on purpose: a `login_validator` that raises is a + 401 by documented contract (CHANGELOG 0.1.24). + +## Step 5: request path, cache, body, specific exceptions + +### 5a. Handler + +`endpoint_handler.py` now holds everything that serves one route: `MiddlewareChain` +(frozen dataclass over the app's middleware list, `before`/`after`, replacing the two +functions of `middleware_runner.py`), `EndpointGuard` (from `auth_checker.py`), +`_CrudArgument` + `_Verb.call_arguments` (replacing the five argument-adapter functions) and +`EndpointHandler._json_body` (replacing `_read_body`, now `request.json()` with +`except (json.JSONDecodeError, UnicodeDecodeError)`: an empty or malformed body is still `{}` +and a 422; a dropped connection propagates). `auth_checker.py` and `middleware_runner.py` +deleted (CHANGELOG `Removed`, `[tool.mypy]` list trimmed). `endpoint_handler.py` is fully +type-checked by mypy, so the moved code is typed. + +### 5b. Cache + +`cache.EndpointCache(endpoint_name, config)` replaces `cache_helper.ResponseCache(cls)` and is +built once per route in `EndpointHandler.__init__` (before: up to three instances per request). +It reaches Redis through `RedisCacheBackend.shared()` (a `@classmethod` + `functools.cache`, +replacing `_default_backend`; still lazy, so `LIGHTAPI_REDIS_URL` set after import counts) on +every call, never binding the client early: tests build the app before patching. The Redis +boundary is `RedisCacheBackend`, which now catches only `redis.RedisError` (plus +`json.JSONDecodeError`/`UnicodeDecodeError` on `get`, `ValueError` on `from_url`) and keeps +the "no Redis, no cache, requests still answered" contract; `EndpointCache` has no +`try/except` (the body it stores is JSON it produced). `get_cached`, `set_cached`, +`_ping_redis` and `cache_helper.py` are gone; `invalidate_cache_prefix` stays (documented). +Tests that patched the old module functions now patch `RedisCacheBackend.get/set/ +invalidate_prefix/ping`; the "Redis down" tests use a fake client raising +`redis.ConnectionError` under `patch("lightapi.cache.redis.from_url")` with +`RedisCacheBackend.shared.cache_clear()` around them; `test_cache_backend_lazy.py` sets the +fake client's `get` to return None (its truthy MagicMock only passed before because the +`TypeError` from `json.loads` was swallowed). `[tool.mypy]` list trimmed; CHANGELOG `Removed`. + +### 5c. Pagination and filters + +`pagination.Cursor` (`@dataclass(frozen=True, slots=True)`, `last_id: int`, `encode()`, +`Cursor.decode(text)`) replaces `encode_cursor`/`decode_cursor`; `Cursor.INVALID` names the +exceptions a client-supplied cursor can fail with, and `CursorPaginator.paginate` catches only +those (before: `except Exception: pass` around decode and the column lookup, which is now a +plain `getattr(qs, "columns_clause_froms", ())`). `_current_page` (PR #46) is a static method +of `PageNumberPaginator`. `filters.py`: SQLAlchemy imports at the top; +`_coerce_filter_value` catches `(ValueError, AttributeError, IndexError)`. `test_paginate_async` +reads the cursor through `Cursor.decode(...).last_id`. + +### 5d. Session and repository + +`SessionManager` gains `sync_engine` (unwraps an `AsyncEngine`, by its own `is_async`) and +`run_blocking(fn, *args)` (from `session.py`; the single-connection-pool rule moves with it). +`RestEndpoint._session_manager_or_raise()` is the one place that raises the "No +session_manager configured" error; `_get_engine` and `_get_async_engine` (documented) delegate +to it, and the per-request `AsyncEngine` import in `_get_engine` is gone. `session.py` keeps +only the two public context managers (the in-function `AsyncSession` import stays: optional +extra). `repository.py`: `_utcnow` and `_in_pk_range` (PR #46) are methods of `Repository`, +with the 64-bit bounds as class constants. + +### 5e. Remaining broad excepts + +`lightapi.py`: `_create_tables` catches `SQLAlchemyError` (the warning on a failed +`create_all` stays; a programming error now propagates); the async branch is its own method +that returns early when no loop is running instead of wrapping the whole thread hand-off in +`except RuntimeError`. B19 (asyncio.run on a worker thread) is unchanged. `rest.py`: +`get_type_hints` fallback catches `(NameError, TypeError, SyntaxError, AttributeError)`. +Remaining `except Exception` in the package, all deliberate: `_login.py` (validator raised -> +401, documented), `session.py` (rollback + re-raise), FROZEN `RedisCache` body. + +## Step 7: `rest.py` + +- `_get_queryset_async` reuses `_get_queryset` and awaits an awaitable result (the two + copies differed only by that `await`). +- `schema.FieldProjection(NamedTuple)` (fields, read, write) with `from_serializer`, + `for_method`, `apply` replaces `normalise_serializer`, `resolve_fields` and + `_apply_fields`; `_meta["serializer_normalised"]` is still the same tuple. + `SchemaFactory.row_to_dict`, `SchemaFactory.patch_schema` (same `lru_cache`), + `_schema_pair` and `_reflected_annotation` are methods. `RestEndpoint._fields_for(verb)` + resolves the projection once per request instead of once per serialized row. Tests + `test_pipeline.py`, `test_serializer.py`, `test_repository.py` call the methods (same + assertions). +- Column building moved to its owner: `DeclaredTable.from_endpoint(cls, name, namespace)` + with `_field_annotations`, `_column_for`, `_is_optional`, `_auto_columns` and the + annotation-to-column `_TYPE_MAP` in `table_mapping.py`. `TableSource` is now an ABC holding + the two helpers both sources share (`_table_name`, `_strip_field_infos`); `_reflect_sync` + and `_reflect_async` are `ReflectedTable` static methods. `RestEndpointMeta` keeps + `_reject_redeclared_auto_fields`, `_parsed_meta` and `_allowed_methods` as its own + methods. `rest.py` has no module-level function left. + +## Step 8: `LightApi` and the configuration loaders + +- `lightapi.py`: the seven module-level helpers are `LightApi` methods + (`_engine_from_url`, `_as_rate_limiter`, `_require_rest_endpoint`, `_has_async_handlers`, + `_require_async_engine`, `_validate_async_dependencies`; `_checked_mode` had already become + `ExecutionMode.from_user`). `_LOGIN_RATE_LIMITS` and `_ASYNC_HANDLER_NAMES` (derived from + `CRUD_VERBS`) are class constants; the dialect-to-driver map is a module `Final`. + `RateLimiter.from_limits(limits, defaults)` builds a limiter from a dict. + `tests/test_async_session.py` calls `LightApi._validate_async_dependencies`. +- `yaml_config.py` replaces `yaml_loader.py` + `yaml_names.py` + `yaml_schema.py`: the eleven + loader functions are methods of the pydantic models that hold the data + (`AuthConfig.to_authentication`/`resolved_permission`, `FilteringConfig.to_filtering`, + `PaginationConfig.to_pagination`, `MetaConfig.build_meta_class`/`endpoint_bases`, + `FieldSpec.annotation`/`field_info`, `EndpointConfig.build_endpoint_class`, + `LightAPIConfig.from_file`/`build_app`/`resolve_name`/`resolve_callable`); the name registry + is a module `Final` built with top-level imports (no cycle: none of those modules import + `yaml_config` or `lightapi.lightapi`); `HTTP_METHOD_MIXINS` in `constants.py` maps verb + names to the public `HttpMethod` mixins for both config loaders. Every error text is + unchanged. `tests/test_yaml_config.py` imports from `yaml_config` and calls + `entry.build_endpoint_class(...)` / `LightAPIConfig.resolve_name(...)`. +- `_dict_config_loader.py` (deprecated path, Henry asked to include it): `DictConfig` + (frozen dataclass over the dict, `build`) and `DictAuthKind(str, Enum)` replace + `load_from_dict`, `_route_to_class_name` and the `"jwt"/"basic"` literals; an unknown + `auth:` value is still ignored. Imports come from submodules, never from the `lightapi` + package, because `lightapi.py` now imports this module at the top. +- `[tool.mypy]`: `yaml_loader` -> `yaml_config`. CHANGELOG `Removed`. + +## Phase 3: production readiness + +### Found by the differential harness, fixed + +Recording the final wheel crashed in the `async-no-mode` scenario (an `AsyncEngine` passed +to `LightApi` without `mode="async"` and without async handlers, which the README calls a +one-line swap): `_create_tables` ran `metadata.create_all(bind=AsyncEngine)`, whose +`AttributeError` the old `except Exception` turned into a warning, and the two requests were +recorded as 500s. With the narrowed `except SQLAlchemyError` the error reached `build_app()`. +Fix: `_create_tables` skips table creation for an `AsyncEngine` on a sync app and logs a +warning naming the cause, so the app builds and behaves as before (the two 500s). Regression +test added (`caplog`); 3.11 run before the fix: 567 passed. + +### Measurements (final commit of the pass vs the merged base d24c7f3) + +| Measure | Base (d24c7f3) | Final | +|---|---|---| +| pytest 3.10 (`--isolated`) | 547 passed, 19 skipped, 1 xfailed, 2 xpassed | 568 passed, 19 skipped, 1 xfailed, 2 xpassed | +| pytest 3.11 / 3.12 / 3.13 | 547 passed (3.13 measured) | 567 (3.11, before the last fix), 568, 568 | +| coverage (3.10) | 90% (2136 statements, 213 missed) | 92% (2174 statements, 176 missed) | +| ruff check / format | clean | clean | +| mypy lightapi | 0 errors, 35 files | 0 errors, 30 files | +| build + twine | PASSED | PASSED | +| `lightapi/` files / lines | 35 / 4647 | 30 / 4675 | +| classes / enums | 69 / 2 | 81 / 10 | +| module-level functions | 72 | 6 (`Field`, `get_sync_session`, `get_async_session`, `server.main`, `_coerce_filter_value`, `invalidate_cache_prefix`: all public or documented) | +| `except Exception` | 16 | 3 (`_login.py` documented 401; two rollback-and-reraise in `session.py`) | +| in-function imports | ~30 | 2 (both the optional `sqlalchemy[asyncio]` extra) | +| HTTP snapshot (`18_full_api.py`, 41 requests) | recorded | 41/41 identical | +| differential harness vs base | 412 steps | 412/412 identical | +| differential harness vs published 0.1.29 | | 404 identical, 6 declared (#44's), 2 undeclared that come from PR #46 (`?page=abc`, `?page=0`; not in `compare.py`'s `ALLOWED` nor in the CHANGELOG) | +| warnings in the suite | 656 (547 tests) | 714 (568 tests); same four kinds: SQLAlchemy class-name reuse, `Column.copy()` deprecation, pytest `env` option, Starlette `on_startup` deprecation | +| `mkdocs build --strict` | | passes with the two new pages (not in `docs/.pages`, on purpose) | + +Lines: the dead code was removed in PR #44, so this pass is structure, not deletion. Files +that grew and why: `constants.py` +122 (six enums and `ApiError` that replaced literals in +nine modules), `table_mapping.py` +133 (took the column building from `rest.py`, which lost +154), `endpoint_handler.py` +109 (absorbed `auth_checker.py` and `middleware_runner.py`, +together 87 lines), `cache.py` +60 (absorbed `cache_helper.py`, 82 lines, minus the removed +module functions), `yaml_config.py` 553 (replaces 664 lines of three modules). + +Not done, on purpose (plan "Pare e anote"): `_meta` stays a dict (documented); `partial: bool` +in the public `update()` stays; `Cache.vary_on`, `Config.jwt_expiration` and +`Authentication.rate_limiter` are public fields that nothing reads; FROZEN bodies untouched +(`jwt.py:65` literal `"OPTIONS"`, raw 401s in `base.py`/`jwt.py`); B19, B20, T8-01 untouched. + +Not pushed. The prompt forbids `git push`; the stacked PR on `test/release-validation` waits +for Henry's go-ahead. diff --git a/docs/technical-reference/handlers.md b/docs/technical-reference/handlers.md index dd4474e..4f2955b 100644 --- a/docs/technical-reference/handlers.md +++ b/docs/technical-reference/handlers.md @@ -12,10 +12,10 @@ When `app.register({"/items": ItemEndpoint})` is called, LightAPI registers two | Route | Methods | Handler | |-------|---------|---------| -| `/items` | `GET`, `POST` | `_make_collection_handler(cls)` | -| `/items/{id:int}` | `GET`, `PUT`, `PATCH`, `DELETE` | `_make_detail_handler(cls)` | +| `/items` | `GET`, `POST` | `EndpointHandler.for_collection(cls, ...).handle` | +| `/items/{id:int}` | `GET`, `PUT`, `PATCH`, `DELETE` | `EndpointHandler.for_detail(cls, ...).handle` | -Both handlers are closures that instantiate the endpoint class per request and dispatch to the appropriate CRUD method. +Both handlers instantiate the endpoint class per request and dispatch to the appropriate CRUD method. ## Request Dispatch Flow diff --git a/docs/tutorial/endpoints.md b/docs/tutorial/endpoints.md index e07eec8..cf48608 100644 --- a/docs/tutorial/endpoints.md +++ b/docs/tutorial/endpoints.md @@ -57,7 +57,7 @@ Beyond Pydantic's built-in constraints, LightAPI processes: | `unique=True` | `UNIQUE` constraint | | `index=True` | Database index | | `foreign_key="table.col"` | Foreign key reference | -| `decimal_places=N` | Precision for `Decimal` | +| `decimal_places=N` | A `Decimal` with more than N decimal places is rejected (422) | | `exclude=True` | Skip column creation; field is schema-only | | `default=` | Column-level default | diff --git a/examples/12_queryset.py b/examples/12_queryset.py index 6355014..ebfd40a 100644 --- a/examples/12_queryset.py +++ b/examples/12_queryset.py @@ -22,6 +22,8 @@ curl "http://localhost:8000/books?min_price=50" """ +import contextlib + from sqlalchemy import create_engine, select from sqlalchemy.pool import StaticPool @@ -51,10 +53,8 @@ def queryset(self, request): # Filter by minimum price min_price = request.query_params.get("min_price") if min_price: - try: + with contextlib.suppress(ValueError): qs = qs.where(cls._model_class.price >= float(min_price)) - except ValueError: - pass return qs diff --git a/examples/14_background.py b/examples/14_background.py index ae6c611..87c5261 100644 --- a/examples/14_background.py +++ b/examples/14_background.py @@ -37,7 +37,7 @@ async def audit_log(action: str, item_id: int) -> None: """Background task that logs actions.""" - logger.info(f"AUDIT: {action} item_id={item_id}") + logger.info("AUDIT: %s item_id=%s", action, item_id) class ItemEndpoint(RestEndpoint, HttpMethod.GET, HttpMethod.POST): diff --git a/examples/15_yaml_config.py b/examples/15_yaml_config.py index ae24caa..d685a09 100644 --- a/examples/15_yaml_config.py +++ b/examples/15_yaml_config.py @@ -16,8 +16,8 @@ The equivalent YAML config is in examples/config.yaml """ -import os import tempfile +from pathlib import Path from lightapi import LightApi @@ -66,7 +66,7 @@ def simple_login_validator(username: str, password: str): print(f"Registered routes: {[r.path for r in app._routes]}") app.run(host="0.0.0.0", port=8000, debug=True) finally: - os.unlink(config_path) + Path(config_path).unlink() if __name__ == "__main__": diff --git a/examples/18_full_api.py b/examples/18_full_api.py index 2af612b..570f802 100644 --- a/examples/18_full_api.py +++ b/examples/18_full_api.py @@ -22,7 +22,6 @@ import os import time -from typing import Optional from sqlalchemy import create_engine from sqlalchemy.pool import StaticPool @@ -115,7 +114,7 @@ class AuthorEndpoint(RestEndpoint, HttpMethod.GET, HttpMethod.POST): """GET + POST only - JWT protected.""" name: str = Field(min_length=1, max_length=150) - bio: Optional[str] = None + bio: str | None = None class Meta: authentication = Authentication( diff --git a/examples/postgres_full.py b/examples/postgres_full.py index 92d2684..3256bc8 100644 --- a/examples/postgres_full.py +++ b/examples/postgres_full.py @@ -21,7 +21,6 @@ import logging import os -from typing import Optional from pydantic import Field from sqlalchemy import select @@ -42,6 +41,8 @@ "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres", ) +logger = logging.getLogger(__name__) + # ── Tracking list for background tasks (demo) ──────────────────────────────── _audit_log: list[dict] = [] @@ -49,7 +50,7 @@ async def _audit(action: str, table: str, row_id: int) -> None: """Simulated async background audit logger.""" _audit_log.append({"action": action, "table": table, "id": row_id}) - logging.info("AUDIT: %s %s id=%s", action, table, row_id) + logger.info("AUDIT: %s %s id=%s", action, table, row_id) # ── Middleware ──────────────────────────────────────────────────────────────── @@ -60,8 +61,8 @@ class RequestLogMiddleware(Middleware): def process(self, request: Request, response: Response | None) -> None: if response is None: - logging.info("→ %s %s", request.method, request.url.path) - return None + logger.info("→ %s %s", request.method, request.url.path) + return class TimingMiddleware(Middleware): @@ -70,7 +71,7 @@ class TimingMiddleware(Middleware): async def process(self, request: Request, response: Response | None) -> None: if response is not None: response.headers["X-Powered-By"] = "LightAPI-async" - return None + return # ── Endpoints ───────────────────────────────────────────────────────────────── @@ -80,7 +81,7 @@ class Author(RestEndpoint): """Public author resource — AllowAny, paginated, filterable, serialized.""" name: str = Field(min_length=1, max_length=200) - bio: Optional[str] = Field(default=None) + bio: str | None = Field(default=None) active: bool = Field(default=True) class Meta: @@ -117,7 +118,7 @@ class Book(RestEndpoint): title: str = Field(min_length=1, max_length=300) author_id: int = Field(gt=0) - isbn: Optional[str] = Field(default=None, max_length=20) + isbn: str | None = Field(default=None, max_length=20) published: bool = Field(default=False) page_count: int = Field(default=0, ge=0) diff --git a/examples/smoke_async.py b/examples/smoke_async.py index 23327f5..83e7450 100644 --- a/examples/smoke_async.py +++ b/examples/smoke_async.py @@ -4,7 +4,6 @@ """ import asyncio -from typing import Optional import httpx from pydantic import Field @@ -27,7 +26,7 @@ class Item(RestEndpoint): name: str = Field(min_length=1) quantity: int = Field(ge=0) secret: str = Field(default="internal") - notes: Optional[str] = Field(default=None) + notes: str | None = Field(default=None) class Meta: authentication = Authentication(permission=AllowAny) diff --git a/examples/v2_full_demo.py b/examples/v2_full_demo.py index feacfa7..d4d54fe 100644 --- a/examples/v2_full_demo.py +++ b/examples/v2_full_demo.py @@ -38,7 +38,6 @@ import json import os import time -from typing import Optional from sqlalchemy import create_engine from sqlalchemy import select as sa_select @@ -89,7 +88,7 @@ def process(self, request: Request, response: Response | None) -> Response | Non ) try: body = json.loads(response.body) - except Exception: + except (json.JSONDecodeError, UnicodeDecodeError, AttributeError): # 204 No Content or non-JSON — passthrough return response return JSONResponse( @@ -169,7 +168,7 @@ class AuthorEndpoint(RestEndpoint, HttpMethod.GET, HttpMethod.POST): All requests require a valid JWT (IsAuthenticated).""" name: str = Field(min_length=1, max_length=150) - bio: Optional[str] = None + bio: str | None = None class Meta: authentication = Authentication( diff --git a/lightapi/__init__.py b/lightapi/__init__.py index 3c17ff4..9a31d80 100644 --- a/lightapi/__init__.py +++ b/lightapi/__init__.py @@ -13,12 +13,7 @@ from lightapi.config import Authentication, Cache, Filtering, Pagination, Serializer # Backward-compatible re-exports from core.py -from lightapi.core import ( - AuthenticationMiddleware, - CORSMiddleware, - Middleware, - Response, -) +from lightapi.core import AuthenticationMiddleware, CORSMiddleware, Middleware, Response from lightapi.exceptions import ConfigurationError, SerializationError from lightapi.fields import Field from lightapi.filters import FieldFilter, OrderingFilter, SearchFilter @@ -27,46 +22,37 @@ from lightapi.rate_limiter import RateLimiter from lightapi.rest import RestEndpoint from lightapi.schema import SchemaFactory -from lightapi.session import get_async_session, get_sync_session # noqa: E402 +from lightapi.session import get_async_session, get_sync_session __all__ = [ - # Core - "LightApi", - "RestEndpoint", - "Field", - "HttpMethod", - # Config + "AllowAny", "Authentication", - "Cache", - "Filtering", - "Pagination", - "Serializer", - # Auth + "AuthenticationMiddleware", "BaseAuthentication", "BasePermission", "BasicAuthentication", - "JWTAuthentication", - "AllowAny", - "IsAuthenticated", - "IsAdminUser", - # Rate Limiter - "RateLimiter", - # Filters + "CORSMiddleware", + "Cache", + "ConfigurationError", + "Field", "FieldFilter", - "SearchFilter", - "OrderingFilter", - # Schema - "SchemaFactory", - # Middleware (backward-compat) + "Filtering", + "HttpMethod", + "IsAdminUser", + "IsAuthenticated", + "JWTAuthentication", + "LightApi", "Middleware", - "CORSMiddleware", - "AuthenticationMiddleware", - "Response", + "OrderingFilter", + "Pagination", + "RateLimiter", "RedisCache", - # Exceptions - "ConfigurationError", + "Response", + "RestEndpoint", + "SchemaFactory", + "SearchFilter", "SerializationError", - # Session helpers - "get_sync_session", + "Serializer", "get_async_session", + "get_sync_session", ] diff --git a/lightapi/_dict_config_loader.py b/lightapi/_dict_config_loader.py index b904bc4..a316570 100644 --- a/lightapi/_dict_config_loader.py +++ b/lightapi/_dict_config_loader.py @@ -1,153 +1,128 @@ -"""Simple programmatic configuration loader for LightAPI. +"""Programmatic configuration from a plain dict (``LightApi.from_dict``, deprecated). -Provides a simpler alternative to YAML config for programmatic setup. +Removed in 0.2.0 together with ``from_dict``; nothing else imports this module. """ from __future__ import annotations -from typing import Any - - -def load_from_dict( - app_cls: type, - config: dict[str, Any], - **overrides: Any, -) -> Any: - """Load LightApi configuration from a Python dictionary. - - Args: - app_cls: LightApi class - config: Configuration dictionary with keys: - - database_url: str - - mode: "sync" | "async" - - cors: list[str] - - endpoints: dict[str, dict] - - login_validator: callable - overrides: Additional kwargs to override config values - - Example:: - - config = { - "database_url": "sqlite:///db.sqlite3", - "endpoints": { - "/books": { - "fields": {"title": str, "author": str}, - "auth": "jwt", - }, - }, - } +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any + +from lightapi.authentication import ( + AllowAny, + BaseAuthentication, + BasicAuthentication, + JWTAuthentication, +) +from lightapi.config import Authentication, Filtering, Pagination +from lightapi.constants import HTTP_METHOD_MIXINS +from lightapi.rest import RestEndpoint + +if TYPE_CHECKING: + from lightapi.lightapi import LightApi + + +class DictAuthKind(str, Enum): + """The ``auth:`` values a dict config accepts; anything else is ignored.""" + + JWT = "jwt" + BASIC = "basic" + + @property + def backend(self) -> type[BaseAuthentication]: + if self is DictAuthKind.BASIC: + return BasicAuthentication + return JWTAuthentication + + +@dataclass(frozen=True) +class DictConfig: + """A LightApi configuration given as a dict. + + Keys: ``database_url``, ``mode`` ("sync" or "async"), ``cors`` (list of origins), + ``endpoints`` ({route: {fields, auth, paginate, filters, methods}}) and + ``login_validator`` (callable). """ - from lightapi import RestEndpoint - from lightapi.auth import AllowAny, JWTAuthentication - from lightapi.config import Authentication, Pagination - - database_url = config.get("database_url") - mode = config.get("mode", "sync") - cors_origins = config.get("cors") - endpoints_config = config.get("endpoints", {}) - login_validator = config.get("login_validator") - - # Create app instance - constructor_kwargs: dict[str, Any] = { - "database_url": database_url, - "mode": mode, - "cors_origins": cors_origins, - "login_validator": login_validator, - } - constructor_kwargs.update(overrides) - - instance = app_cls(**constructor_kwargs) - - # Build endpoint classes from config - mapping: dict[str, type] = {} - - for route, endpoint_config in endpoints_config.items(): - fields = endpoint_config.get("fields", {}) - auth = endpoint_config.get("auth") - paginate = endpoint_config.get("paginate") - filters = endpoint_config.get("filters") - methods = endpoint_config.get("methods") - - # Build class attributes - class_attrs: dict[str, Any] = {} - - # Add fields — __annotations__ is required for RestEndpointMeta to - # detect field names; values must be Field() instances (or absent). - annotations: dict[str, Any] = {} - for field_name, field_type in fields.items(): - if isinstance(field_type, type): - annotations[field_name] = field_type - else: - annotations[field_name] = type(field_type) - class_attrs["__annotations__"] = annotations - - # Build Meta class - meta_attrs: dict[str, Any] = {} - if auth: - if auth == "jwt": - meta_attrs["authentication"] = Authentication( - backend=JWTAuthentication, - permission=AllowAny, - ) - elif auth == "basic": - from lightapi.auth import BasicAuthentication + config: dict[str, Any] + + def build( + self, + app_cls: type[LightApi], + **overrides: Any, # noqa: ANN401 LightApi constructor kwargs, forwarded + ) -> LightApi: + """A configured ``app_cls`` instance with every endpoint registered.""" + constructor_kwargs: dict[str, Any] = { + "database_url": self.config.get("database_url"), + "mode": self.config.get("mode", "sync"), + "cors_origins": self.config.get("cors"), + "login_validator": self.config.get("login_validator"), + } + constructor_kwargs.update(overrides) + instance = app_cls(**constructor_kwargs) - meta_attrs["authentication"] = Authentication( - backend=BasicAuthentication, - permission=AllowAny, + mapping = { + route: self._endpoint_class(route, spec) + for route, spec in self.config.get("endpoints", {}).items() + } + if mapping: + instance.register(mapping) + return instance + + @classmethod + def _endpoint_class(cls, route: str, spec: dict[str, Any]) -> type: + # __annotations__ is what RestEndpointMeta reads for field names; a value + # that is not a type is replaced by its type. + annotations = { + name: value if isinstance(value, type) else type(value) + for name, value in spec.get("fields", {}).items() + } + class_attrs: dict[str, Any] = { + "__annotations__": annotations, + "Meta": type("Meta", (), cls._meta_attributes(spec)), + } + # HttpMethod mixins must be real bases: RestEndpointMeta scans the MRO. + bases = (RestEndpoint, *cls._method_mixins(spec.get("methods"))) + return type(cls._class_name(route), bases, class_attrs) + + @staticmethod + def _meta_attributes(spec: dict[str, Any]) -> dict[str, Any]: + attrs: dict[str, Any] = {} + auth = spec.get("auth") + if auth: + try: + kind = DictAuthKind(auth) + except ValueError: + kind = None + if kind is not None: + attrs["authentication"] = Authentication( + backend=kind.backend, permission=AllowAny ) - if paginate: - if isinstance(paginate, dict): - meta_attrs["pagination"] = Pagination(**paginate) - elif paginate is True: - meta_attrs["pagination"] = Pagination() - - if filters: - from lightapi.config import Filtering - - if isinstance(filters, dict): - meta_attrs["filtering"] = Filtering(**filters) - elif isinstance(filters, list): - meta_attrs["filtering"] = Filtering(backends=filters) - - Meta = type("Meta", (), meta_attrs) - class_attrs["Meta"] = Meta - - # Resolve HttpMethod mixin bases so _allowed_methods is set correctly. - # Setting class_attrs["__bases__"] has no effect on type(); the bases - # must be passed as the second argument to type(). - http_bases: list[type] = [] - if methods: - from lightapi.methods import HttpMethod - - _method_map = { - "GET": HttpMethod.GET, - "POST": HttpMethod.POST, - "PUT": HttpMethod.PUT, - "PATCH": HttpMethod.PATCH, - "DELETE": HttpMethod.DELETE, - } - for method in methods: - mixin = _method_map.get(method.upper()) - if mixin is not None: - http_bases.append(mixin) - - # Create endpoint class — include HttpMethod mixins in bases so that - # RestEndpointMeta._process picks them up via the MRO scan. - class_name = _route_to_class_name(route) - endpoint_cls = type(class_name, (RestEndpoint, *http_bases), class_attrs) - - mapping[route] = endpoint_cls - - if mapping: - instance.register(mapping) - - return instance - - -def _route_to_class_name(route: str) -> str: - """Convert /books to BooksEndpoint.""" - parts = route.strip("/").split("/") - return "".join(part.capitalize() for part in parts) + "Endpoint" + paginate = spec.get("paginate") + if isinstance(paginate, dict): + attrs["pagination"] = Pagination(**paginate) + elif paginate is True: + attrs["pagination"] = Pagination() + + filters = spec.get("filters") + if isinstance(filters, dict): + attrs["filtering"] = Filtering(**filters) + elif isinstance(filters, list): + attrs["filtering"] = Filtering(backends=filters) + return attrs + + @staticmethod + def _method_mixins(methods: list[str] | None) -> list[type]: + """The mixins of the named methods; an unknown name is skipped, as always.""" + if not methods: + return [] + mixins = (HTTP_METHOD_MIXINS.get(method.upper()) for method in methods) + return [mixin for mixin in mixins if mixin is not None] + + @staticmethod + def _class_name(route: str) -> str: + """Convert /books to BooksEndpoint.""" + parts = route.strip("/").split("/") + return "".join(part.capitalize() for part in parts) + "Endpoint" diff --git a/lightapi/_login.py b/lightapi/_login.py index afbedc2..82faf9a 100644 --- a/lightapi/_login.py +++ b/lightapi/_login.py @@ -3,155 +3,120 @@ from __future__ import annotations import base64 +import inspect import json import logging -from typing import Any, Optional +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, ValidationError from starlette.requests import Request from starlette.responses import JSONResponse -from lightapi.constants import ( - RESPONSE_KEY_DETAIL, - RESPONSE_KEY_TOKEN, - RESPONSE_KEY_USER, - HTTPStatus, -) +from lightapi.constants import ApiError -# JWTAuthentication imported locally where needed to avoid circular import +if TYPE_CHECKING: + from lightapi.authentication.base import BaseAuthentication, LoginValidator + from lightapi.rate_limiter import RateLimiter logger = logging.getLogger(__name__) class LoginRequest(BaseModel): - """Request body for POST /auth/login and /auth/token.""" + """Credentials of POST /auth/login and /auth/token: JSON body or Basic header.""" model_config = ConfigDict(frozen=True) username: str = Field(min_length=1) password: str = Field(min_length=1) + @classmethod + async def from_request(cls, request: Request) -> LoginRequest | None: + """The credentials sent, or None for a malformed Basic header (a 401). + + Without a Basic header the JSON body is validated, so a bad body raises + ValidationError (a 422). Basic credentials are taken as sent, unvalidated, + as they always were. + """ + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Basic "): + parsed = cls.parse_basic_header(auth_header) + if parsed is None: + return None + return cls.model_construct(username=parsed[0], password=parsed[1]) -def _parse_basic_header(auth_header: str) -> tuple[str, str] | None: - """ - Decode Authorization: Basic header. + try: + body = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError): + body = {} + return cls.model_validate(body if body else {}) + + @staticmethod + def parse_basic_header(auth_header: str) -> tuple[str, str] | None: + """(username, password) of an ``Authorization: Basic`` header; None if bad.""" + if not auth_header.lower().startswith("basic "): + return None + try: + token = auth_header.split(" ", 1)[1] + decoded = base64.b64decode(token).decode("utf-8") + except (ValueError, IndexError, UnicodeDecodeError): + return None + parts = decoded.split(":", 1) + if len(parts) != 2: + return None + return parts[0], parts[1] - Returns (username, password) or None if malformed. - """ - if not auth_header.lower().startswith("basic "): - return None - try: - token = auth_header.split(" ", 1)[1] - decoded = base64.b64decode(token).decode("utf-8") - except (ValueError, IndexError, UnicodeDecodeError): - return None - parts = decoded.split(":", 1) - if len(parts) != 2: - return None - return parts[0], parts[1] - - -async def _parse_credentials(request: Request) -> tuple[str, str] | None: - """ - Extract (username, password) from request. - - If Authorization: Basic present: returns (u, p) or None if malformed. - - If no Basic header: reads body, validates with LoginRequest. - Returns (u, p) if valid. Raises ValidationError for body (caller returns 422). - - None means malformed Basic (caller returns 401). - """ - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Basic "): - return _parse_basic_header(auth_header) - - body = await _read_body(request) - parsed = LoginRequest.model_validate(body if body else {}) - return parsed.username, parsed.password - - -async def _read_body(request: Request) -> dict[str, Any]: - """Read JSON body; return {} on empty or invalid.""" - try: - body = await request.body() - return json.loads(body) if body else {} - except (json.JSONDecodeError, TypeError): - return {} - - -async def login_handler( - request: Request, - *, - has_jwt: bool, - jwt_expiration: int | None = None, - jwt_extra_claims: list[str] | None = None, - jwt_algorithm: str | None = None, - rate_limiter: Optional[Any] = None, - auth_backend: Optional[Any] = None, -) -> JSONResponse: - """ - Handle POST /auth/login and POST /auth/token. +class LoginEndpoint: + """POST /auth/login and POST /auth/token. Returns 422 for body validation, 401 for malformed Basic, invalid credentials, - or any exception raised by the validator; 200 with token+user (JWT) or user (Basic). + or any exception raised while validating; 200 with whatever the backend puts + in a login response (token and user for JWT, user for Basic). """ - # Apply rate limiting if a rate limiter is provided - if rate_limiter is not None: - is_limited, window = rate_limiter.is_rate_limited(request, endpoint="auth") - if is_limited: - return rate_limiter.get_rate_limit_response(request, window) - - if request.method != "POST": - return JSONResponse( - {RESPONSE_KEY_DETAIL: "method not allowed"}, - status_code=HTTPStatus.METHOD_NOT_ALLOWED, - headers={"Allow": "POST"}, - ) - try: - creds = await _parse_credentials(request) - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + def __init__( + self, + backend: BaseAuthentication, + login_validator: LoginValidator | None, + rate_limiter: RateLimiter, + ) -> None: + self._backend = backend + self._login_validator = login_validator + self._rate_limiter = rate_limiter + + async def handle(self, request: Request) -> JSONResponse: + # The limiter names the exhausted window; None when the request passes. + _, exhausted_window = self._rate_limiter.is_rate_limited( + request, endpoint="auth" ) + if exhausted_window is not None: + return self._rate_limiter.get_rate_limit_response(request, exhausted_window) - if creds is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "Invalid credentials"}, - status_code=HTTPStatus.UNAUTHORIZED, - ) - - username, password = creds - - # Use auth_backend's validate_credentials if provided - payload = None - if auth_backend is not None: try: - payload = auth_backend.validate_credentials(username, password) - except Exception as e: - logger.warning("validate_credentials raised: %s", e) - return JSONResponse( - {RESPONSE_KEY_DETAIL: "Invalid credentials"}, - status_code=HTTPStatus.UNAUTHORIZED, + credentials = await LoginRequest.from_request(request) + except ValidationError as exc: + return ApiError.validation_failed(exc.errors()) + + user = None + if credentials is not None: + user = await self._validated_user( + credentials.username, credentials.password ) - - if payload is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "Invalid credentials"}, - status_code=HTTPStatus.UNAUTHORIZED, - ) - - if has_jwt: - from lightapi.authentication import JWTAuthentication - - jwt_auth = JWTAuthentication(algorithm=jwt_algorithm) - if jwt_extra_claims and isinstance(payload, dict): - token_payload = {k: payload[k] for k in jwt_extra_claims if k in payload} - if not token_payload: - token_payload = payload - else: - token_payload = payload - token = jwt_auth.generate_token(token_payload, expiration=jwt_expiration) - return JSONResponse({RESPONSE_KEY_TOKEN: token, RESPONSE_KEY_USER: payload}) - - return JSONResponse({RESPONSE_KEY_USER: payload}) + if user is None: + return ApiError.INVALID_CREDENTIALS.response() + return JSONResponse(self._backend.login_response(user)) + + async def _validated_user( + self, username: str, password: str + ) -> dict[str, Any] | None: + """The app's login_validator wins; otherwise the backend's own override.""" + validate = self._login_validator or self._backend.validate_credentials + try: + user = validate(username, password) + # The README shows `async def validate_credentials` on a subclass. + if inspect.isawaitable(user): + user = await user + except Exception: # noqa: BLE001 documented contract: any error is a 401 + logger.warning("validate_credentials raised", exc_info=True) + return None + return user diff --git a/lightapi/auth.py b/lightapi/auth.py index 34fdb35..8ff5192 100644 --- a/lightapi/auth.py +++ b/lightapi/auth.py @@ -1,7 +1,9 @@ """LightAPI Authentication - Backward compatibility module. This module re-exports authentication classes from the new authentication submodule. -For new code, use: from lightapi.authentication import JWTAuthentication, BasicAuthentication +For new code, use:: + + from lightapi.authentication import JWTAuthentication, BasicAuthentication """ from lightapi.authentication import ( @@ -15,11 +17,11 @@ ) __all__ = [ + "AllowAny", "BaseAuthentication", "BasePermission", - "AllowAny", - "IsAuthenticated", - "IsAdminUser", "BasicAuthentication", + "IsAdminUser", + "IsAuthenticated", "JWTAuthentication", ] diff --git a/lightapi/auth_checker.py b/lightapi/auth_checker.py deleted file mode 100644 index f4a5a96..0000000 --- a/lightapi/auth_checker.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Authentication and authorization checker.""" - -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from lightapi.authentication import AllowAny -from lightapi.constants import HTTPStatus - - -def check_auth(cls: type, request: Request) -> Response | None: - """Run authentication + permission checks; return 401/403 response or None.""" - - auth_cfg = cls._meta.get("authentication") - if auth_cfg is None: - return None - - backend = auth_cfg.backend - permission_cls = auth_cfg.permission - is_per_method = isinstance(permission_cls, dict) - - if is_per_method: - perm_cls = permission_cls.get(request.method) - if perm_cls is None: - perm_cls = AllowAny - elif permission_cls is not None: - perm_cls = permission_cls - else: - perm_cls = AllowAny - - # Explicitly setting AllowAny (non-dict) means the endpoint is fully public — - # skip the backend authentication check entirely. - # Note: permission_cls=None defaults to AllowAny but still requires auth - # if a backend is set; only an explicit AllowAny bypasses the backend. - if not is_per_method and permission_cls is AllowAny: - return None - - requires_auth = backend is not None and ( - not is_per_method or perm_cls is not AllowAny - ) - - if requires_auth: - login_validator = getattr(auth_cfg, "_login_validator", None) - - if backend.__name__ == "JWTAuthentication": - authenticator = backend( - expiration=getattr(auth_cfg, "jwt_expiration", None), - algorithm=getattr(auth_cfg, "jwt_algorithm", None), - rate_limiter=getattr(auth_cfg, "rate_limiter", None), - ) - elif backend.__name__ == "BasicAuthentication": - authenticator = backend( - rate_limiter=getattr(auth_cfg, "rate_limiter", None), - login_validator=login_validator, - ) - else: - authenticator = backend() - - authenticated = authenticator.authenticate(request) - - if not authenticated: - return JSONResponse( - {"detail": "Authentication required"}, - status_code=HTTPStatus.UNAUTHORIZED, - headers={"WWW-Authenticate": "Bearer"}, - ) - - if perm_cls is not AllowAny: - if not perm_cls().has_permission(request): - return JSONResponse( - {"detail": "Insufficient permissions"}, - status_code=HTTPStatus.FORBIDDEN, - ) - - return None diff --git a/lightapi/auth_service.py b/lightapi/auth_service.py deleted file mode 100644 index b6f2c96..0000000 --- a/lightapi/auth_service.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Authentication service for LightAPI. - -Extracts auth logic from LightApi class for better SRP. -""" - -from __future__ import annotations - -from typing import Callable, Optional - -from starlette.requests import Request -from starlette.responses import JSONResponse - - -class AuthService: - """Handles authentication and authorization for endpoints. - - Separates auth logic from the main LightApi class. - """ - - def __init__( - self, - login_validator: Optional[Callable] = None, - jwt_secret: Optional[str] = None, - jwt_algorithm: str = "HS256", - jwt_expiration: int = 3600, - ) -> None: - self._login_validator = login_validator - self._jwt_secret = jwt_secret - self._jwt_algorithm = jwt_algorithm - self._jwt_expiration = jwt_expiration - - @property - def login_validator(self) -> Optional[Callable]: - return self._login_validator - - @property - def jwt_secret(self) -> Optional[str]: - return self._jwt_secret - - @property - def jwt_algorithm(self) -> str: - return self._jwt_algorithm - - @property - def jwt_expiration(self) -> int: - return self._jwt_expiration - - def check_auth( - self, - request: Request, - backend: Optional[type], - permission: Optional[type], - login_validator: Optional[Callable] = None, - ) -> tuple[bool, Optional[JSONResponse]]: - """Check authentication and authorization for a request. - - Args: - request: The HTTP request - backend: Authentication backend class - permission: Permission class - login_validator: Login validator function - - Returns: - Tuple of (is_authenticated, error_response) - """ - if backend is None: - return True, None - - # Get validator (from parameter or instance) - validator = login_validator or self._login_validator - - # Instantiate authenticator - try: - if backend.__name__ == "JWTAuthentication": - authenticator = backend( - expiration=getattr(backend, "jwt_expiration", None), - algorithm=getattr(backend, "jwt_algorithm", None), - ) - elif backend.__name__ == "BasicAuthentication": - authenticator = backend(login_validator=validator) - else: - authenticator = backend() - except Exception: - return True, None - - # Authenticate - if not authenticator.authenticate(request): - error_response = getattr( - authenticator, - "get_auth_error_response", - lambda r: JSONResponse( - {"detail": "Authentication credentials invalid."}, status_code=401 - ), - )(request) - return False, error_response - - # Check permission - if permission is not None: - perm = permission() - if not perm.has_permission(request): - return False, JSONResponse( - {"detail": "You do not have permission to perform this action."}, - status_code=403, - ) - - return True, None diff --git a/lightapi/auth_strategy.py b/lightapi/auth_strategy.py deleted file mode 100644 index 52158db..0000000 --- a/lightapi/auth_strategy.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Authentication strategies for LightAPI. - -Provides strategy pattern for different authentication backends. -""" - -from typing import Protocol - -from lightapi.auth import BaseAuthentication, BasicAuthentication, JWTAuthentication - - -class AuthStrategy(Protocol): - """Protocol for authentication strategies.""" - - def create(self, backend: type, config: dict) -> BaseAuthentication: - """Create an authenticator instance.""" - ... - - -class DefaultAuthStrategy: - """Default strategy that creates auth backend as-is.""" - - def create(self, backend: type, config: dict) -> BaseAuthentication: - return backend() - - -class JWTAuthStrategy: - """Strategy for JWT authentication.""" - - def create(self, backend: type, config: dict) -> BaseAuthentication: - return JWTAuthentication( - expiration=config.get("jwt_expiration"), - algorithm=config.get("jwt_algorithm"), - ) - - -class BasicAuthStrategy: - """Strategy for Basic authentication.""" - - def __init__(self, login_validator=None): - self._login_validator = login_validator - - def create(self, backend: type, config: dict) -> BaseAuthentication: - validator = self._login_validator or config.get("login_validator") - return BasicAuthentication(login_validator=validator) - - -class AuthStrategyFactory: - """Factory for creating authentication strategies.""" - - _STRATEGIES: dict[str, AuthStrategy] = { - "JWTAuthentication": JWTAuthStrategy(), - "BasicAuthentication": BasicAuthStrategy(), - } - _default_validator = None - - @classmethod - def set_login_validator(cls, validator): - cls._default_validator = validator - cls._STRATEGIES["BasicAuthentication"] = BasicAuthStrategy(validator) - - @classmethod - def create(cls, backend: type, config: dict) -> BaseAuthentication: - if backend is None: - return None - strategy = cls._STRATEGIES.get(backend.__name__, DefaultAuthStrategy()) - return strategy.create(backend, config) diff --git a/lightapi/authentication/__init__.py b/lightapi/authentication/__init__.py index 9fed5e6..8f13d13 100644 --- a/lightapi/authentication/__init__.py +++ b/lightapi/authentication/__init__.py @@ -14,11 +14,11 @@ from lightapi.authentication.jwt import JWTAuthentication __all__ = [ + "AllowAny", "BaseAuthentication", "BasePermission", - "AllowAny", - "IsAuthenticated", - "IsAdminUser", "BasicAuthentication", + "IsAdminUser", + "IsAuthenticated", "JWTAuthentication", ] diff --git a/lightapi/authentication/base.py b/lightapi/authentication/base.py index 00b3e47..87921cb 100644 --- a/lightapi/authentication/base.py +++ b/lightapi/authentication/base.py @@ -2,13 +2,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ClassVar from starlette.requests import Request from starlette.responses import JSONResponse +from lightapi.constants import ResponseKey + if TYPE_CHECKING: - pass + from lightapi.config import Authentication + +LoginValidator = Callable[[str, str], "dict[str, Any] | None"] class BaseAuthentication: @@ -18,7 +23,23 @@ class BaseAuthentication: By default, allows all requests. """ - def authenticate(self, request: Request) -> bool: + @classmethod + def from_config( + cls, + authentication: Authentication, # noqa: ARG003 backends read what they need + login_validator: LoginValidator | None = None, # noqa: ARG003 same + ) -> BaseAuthentication: + """Build the backend for an endpoint's ``Authentication`` settings. + + Backends that take settings override this; the default needs none. + """ + return cls() + + def login_response(self, user: dict[str, Any]) -> dict[str, Any]: + """Body of a successful ``/auth/login`` for a validated user.""" + return {ResponseKey.USER: user} + + def authenticate(self, request: Request) -> bool: # noqa: ARG002 override point """Authenticate a request. Args: @@ -29,7 +50,10 @@ def authenticate(self, request: Request) -> bool: """ return True - def get_auth_error_response(self, request: Request) -> JSONResponse: + def get_auth_error_response( + self, + request: Request, # noqa: ARG002 public signature + ) -> JSONResponse: """Get the response to return when authentication fails. Args: @@ -44,7 +68,9 @@ def get_auth_error_response(self, request: Request) -> JSONResponse: ) def validate_credentials( - self, username: str, password: str + self, + username: str, # noqa: ARG002 override point + password: str, # noqa: ARG002 override point ) -> dict[str, Any] | None: """Validate user credentials. @@ -67,7 +93,10 @@ class BasePermission: is allowed to access a particular endpoint. """ - def has_permission(self, request: Request) -> bool: + # Whether the endpoint's backend must authenticate the request first. + requires_authentication: ClassVar[bool] = True + + def has_permission(self, request: Request) -> bool: # noqa: ARG002 override point """Check if the request has permission. Args: @@ -82,7 +111,9 @@ def has_permission(self, request: Request) -> bool: class AllowAny(BasePermission): """Permits all requests regardless of authentication state.""" - def has_permission(self, request: Request) -> bool: + requires_authentication = False + + def has_permission(self, request: Request) -> bool: # noqa: ARG002 every request return True diff --git a/lightapi/authentication/basic.py b/lightapi/authentication/basic.py index b9a6b87..13fe31b 100644 --- a/lightapi/authentication/basic.py +++ b/lightapi/authentication/basic.py @@ -2,15 +2,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from http import HTTPStatus +from typing import TYPE_CHECKING, Any from starlette.requests import Request from starlette.responses import JSONResponse -from lightapi._login import _parse_basic_header -from lightapi.authentication.base import BaseAuthentication +from lightapi._login import LoginRequest +from lightapi.authentication.base import BaseAuthentication, LoginValidator +from lightapi.constants import HttpVerb if TYPE_CHECKING: + from lightapi.config import Authentication from lightapi.rate_limiter import RateLimiter @@ -23,21 +27,34 @@ class BasicAuthentication(BaseAuthentication): def __init__( self, - rate_limiter: "RateLimiter | None" = None, + rate_limiter: RateLimiter | None = None, login_validator: Callable[[str, str], dict[str, Any] | None] | None = None, ) -> None: self.rate_limiter = rate_limiter self._login_validator = login_validator + @classmethod + def from_config( + cls, + authentication: Authentication, + login_validator: LoginValidator | None = None, + ) -> BasicAuthentication: + # A subclass with its own constructor keeps being built without arguments. + if cls.__init__ is not BasicAuthentication.__init__: + return cls() + return cls( + rate_limiter=authentication.rate_limiter, login_validator=login_validator + ) + def authenticate(self, request: Request) -> bool: - if request.method == "OPTIONS": + if request.method == HttpVerb.OPTIONS: return True auth_header = request.headers.get("Authorization") if not auth_header: return False - credentials = _parse_basic_header(auth_header) + credentials = LoginRequest.parse_basic_header(auth_header) if credentials is None: return False @@ -50,10 +67,13 @@ def authenticate(self, request: Request) -> bool: request.state.user = payload return True - def get_auth_error_response(self, request: Request) -> JSONResponse: + def get_auth_error_response( + self, + request: Request, # noqa: ARG002 public signature + ) -> JSONResponse: return JSONResponse( {"error": "authentication failed"}, - status_code=401, + status_code=HTTPStatus.UNAUTHORIZED, headers={"WWW-Authenticate": 'Basic realm="Restricted Area"'}, ) diff --git a/lightapi/authentication/jwt.py b/lightapi/authentication/jwt.py index 50f2292..dd0c352 100644 --- a/lightapi/authentication/jwt.py +++ b/lightapi/authentication/jwt.py @@ -9,10 +9,12 @@ from starlette.requests import Request from starlette.responses import JSONResponse -from lightapi.authentication.base import BaseAuthentication +from lightapi.authentication.base import BaseAuthentication, LoginValidator from lightapi.config import config +from lightapi.constants import ResponseKey if TYPE_CHECKING: + from lightapi.config import Authentication from lightapi.rate_limiter import RateLimiter @@ -27,11 +29,37 @@ def __init__( self, algorithm: str | None = None, expiration: int | None = None, - rate_limiter: "RateLimiter | None" = None, + rate_limiter: RateLimiter | None = None, + extra_claims: tuple[str, ...] = (), ) -> None: self.algorithm = algorithm self.expiration = expiration self.rate_limiter = rate_limiter + self.extra_claims = extra_claims + + @classmethod + def from_config( + cls, + authentication: Authentication, + login_validator: LoginValidator | None = None, # noqa: ARG003 interface + ) -> JWTAuthentication: + # A subclass with its own constructor keeps being built without arguments. + if cls.__init__ is not JWTAuthentication.__init__: + return cls() + return cls( + algorithm=authentication.jwt_algorithm, + expiration=authentication.jwt_expiration, + rate_limiter=authentication.rate_limiter, + extra_claims=tuple(authentication.jwt_extra_claims or ()), + ) + + def login_response(self, user: dict[str, Any]) -> dict[str, Any]: + """Token plus user. The token carries ``extra_claims`` when the user has any.""" + claims = {key: user[key] for key in self.extra_claims if key in user} + return { + ResponseKey.TOKEN: self.generate_token(claims or user), + ResponseKey.USER: user, + } def authenticate(self, request: Request) -> bool: if request.method == "OPTIONS": @@ -59,7 +87,10 @@ def authenticate(self, request: Request) -> bool: request.state.user = payload return True - def get_auth_error_response(self, request: Request) -> JSONResponse: + def get_auth_error_response( + self, + request: Request, # noqa: ARG002 public signature + ) -> JSONResponse: return JSONResponse( {"detail": "Authentication credentials invalid."}, status_code=401, @@ -67,7 +98,9 @@ def get_auth_error_response(self, request: Request) -> JSONResponse: ) def validate_credentials( - self, username: str, password: str + self, + username: str, # noqa: ARG002 override point + password: str, # noqa: ARG002 override point ) -> dict[str, Any] | None: """Validate user credentials. diff --git a/lightapi/body_reader.py b/lightapi/body_reader.py deleted file mode 100644 index e1fb07a..0000000 --- a/lightapi/body_reader.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Body reader for parsing request bodies.""" - -import json -from typing import Any - -from starlette.requests import Request - - -async def read_body(request: Request) -> dict[str, Any]: - """Read and parse JSON body; return {} on failure.""" - try: - body = await request.body() - return json.loads(body) if body else {} - except Exception: - return {} diff --git a/lightapi/cache.py b/lightapi/cache.py index 996b8b7..b075d10 100644 --- a/lightapi/cache.py +++ b/lightapi/cache.py @@ -1,71 +1,100 @@ +# BaseCache's no-op get()/set() ignore their arguments by design (FROZEN). +# ruff: noqa: ARG002 from __future__ import annotations import json import logging import os -from typing import Any, Dict, Optional, Protocol +from collections.abc import Awaitable, Callable +from functools import cache +from http import HTTPStatus +from typing import TYPE_CHECKING, Any import redis +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request +from starlette.responses import JSONResponse, Response -from lightapi.constants import DEFAULT_CACHE_TTL, DEFAULT_REDIS_URL - -logger = logging.getLogger(__name__) +from lightapi.constants import DEFAULT_REDIS_URL, HttpVerb +if TYPE_CHECKING: + from lightapi.config import Cache -class CacheBackend(Protocol): - """Protocol for cache backends.""" - - def get(self, key: str) -> Optional[Dict[str, Any]]: ... - def set(self, key: str, value: Dict[str, Any], timeout: int = 300) -> bool: ... - def delete(self, key: str) -> bool: ... +logger = logging.getLogger(__name__) class RedisCacheBackend: - """Redis cache backend implementation.""" + """The Redis client behind ``Meta.cache``. + + Every method answers None/False when Redis cannot be reached: an endpoint + with a cache keeps serving from the database, by design. + """ def __init__(self, redis_url: str | None = None) -> None: self._redis_url = redis_url or os.environ.get( "LIGHTAPI_REDIS_URL", DEFAULT_REDIS_URL ) - self._client = None + self._client: redis.Redis | None = None + + @classmethod + @cache + def shared(cls) -> RedisCacheBackend: + """The process-wide client, built on first use. + + Building it at import time fixed LIGHTAPI_REDIS_URL to whatever the + environment held before ``import lightapi``. + """ + return cls() - def _get_client(self) -> Optional["redis.Redis"]: + def _get_client(self) -> redis.Redis | None: if self._client is None: try: - self._client = redis.from_url(self._redis_url, socket_connect_timeout=1) - except Exception: + # A Redis that accepts the connection and then hangs would hold a + # worker thread per cached request; a slow reply is a cache miss. + # 1s is the connect timeout the code base already uses, not a + # measured value. + self._client = redis.from_url( + self._redis_url, socket_connect_timeout=1, socket_timeout=1 + ) + except (ValueError, redis.RedisError): + logger.warning( + "Cache disabled: cannot build a Redis client from " + "LIGHTAPI_REDIS_URL", + exc_info=True, + ) return None return self._client - def get(self, key: str) -> Optional[Dict[str, Any]]: + def get(self, key: str) -> Any | None: # noqa: ANN401 whatever JSON was cached client = self._get_client() if client is None: return None try: raw = client.get(key) return json.loads(raw) if raw else None - except Exception: + except (redis.RedisError, json.JSONDecodeError, UnicodeDecodeError): + logger.warning( + "Cache read failed for %s; serving from the database", + key, + exc_info=True, + ) return None - def set(self, key: str, value: Dict[str, Any], timeout: int = 300) -> bool: + def set( + self, + key: str, + value: Any, # noqa: ANN401 any JSON value + timeout: int = 300, + ) -> bool: client = self._get_client() if client is None: return False try: client.setex(key, timeout, json.dumps(value)) - return True - except Exception: - return False - - def delete(self, key: str) -> bool: - client = self._get_client() - if client is None: - return False - try: - client.delete(key) - return True - except Exception: + except redis.RedisError: + logger.warning("Cache write failed for %s", key, exc_info=True) return False + return True def invalidate_prefix(self, prefix: str) -> bool: client = self._get_client() @@ -75,9 +104,10 @@ def invalidate_prefix(self, prefix: str) -> bool: keys = list(client.scan_iter(f"{prefix}*")) if keys: client.delete(*keys) - return True - except Exception: + except redis.RedisError: + logger.warning("Cache invalidation failed for %s*", prefix, exc_info=True) return False + return True def ping(self) -> bool: client = self._get_client() @@ -85,110 +115,78 @@ def ping(self) -> bool: return False try: return bool(client.ping()) - except Exception: + except redis.RedisError: + logger.warning("Redis is not reachable", exc_info=True) return False -# Default global instance for backward compatibility -_default_backend = RedisCacheBackend() +def invalidate_cache_prefix(prefix: str) -> None: + """Delete all keys that start with *prefix*. Silently ignores errors.""" + RedisCacheBackend.shared().invalidate_prefix(prefix) -class CacheManager: - """Consolidated cache manager class for all cache operations. +class EndpointCache: + """Caches and invalidates the GET responses of one RestEndpoint route. - Provides a unified interface for caching with configurable backends. - Supports both Redis and no-op caching. + Built once per route. Without a ``Meta.cache`` it is a no-op: every method + still runs fn()/checks request.method, so the handler never branches on + whether caching is configured. """ - def __init__(self, backend: Optional[CacheBackend] = None) -> None: - """Initialize the cache manager. - - Args: - backend: Optional cache backend. If None, uses RedisCacheBackend. - """ - self._backend = backend or _default_backend - - def get(self, key: str) -> Any | None: - """Retrieve cached value. - - Args: - key: Cache key - - Returns: - Cached value or None - """ - return self._backend.get(key) - - def set(self, key: str, value: Any, ttl: int = DEFAULT_CACHE_TTL) -> bool: - """Store value in cache. - - Args: - key: Cache key - value: Value to cache - ttl: Time to live in seconds - - Returns: - True if successful - """ - return self._backend.set(key, value, ttl) - - def delete(self, key: str) -> bool: - """Delete a cache key. - - Args: - key: Cache key - - Returns: - True if successful - """ - return self._backend.delete(key) - - def invalidate_prefix(self, prefix: str) -> bool: - """Invalidate all keys matching prefix. - - Args: - prefix: Key prefix to match - - Returns: - True if successful - """ - return self._backend.invalidate_prefix(prefix) - - def ping(self) -> bool: - """Check if cache backend is available. - - Returns: - True if backend is reachable - """ - return self._backend.ping() - - -# Global cache manager instance -_cache_manager = CacheManager() - - -def _get_redis() -> "redis.Redis | None": - return _default_backend._get_client() - - -def _ping_redis() -> bool: - """Return True if Redis is reachable.""" - return _default_backend.ping() - - -def get_cached(key: str) -> Any | None: - """Return the cached value for *key* or None on miss / Redis failure.""" - return _default_backend.get(key) + def __init__(self, endpoint_name: str, config: Cache | None) -> None: + self._endpoint_name = endpoint_name + self._config = config + + def serve(self, request: Request, fn: Callable[[], Response]) -> Response: + """Serve from cache on a hit, or call fn() and populate the cache.""" + if self._config is None: + return fn() + cached = self._cached_response(request) + if cached is not None: + return cached + response = fn() + self._store_response(request, response) + return response + + async def serve_async( + self, request: Request, fn: Callable[[], Awaitable[Response]] + ) -> Response: + """serve() for a coroutine; Redis calls run in a worker thread.""" + if self._config is None: + return await fn() + cached = await run_in_threadpool(self._cached_response, request) + if cached is not None: + return cached + response = await fn() + await run_in_threadpool(self._store_response, request, response) + return response + + async def invalidate_after_write(self, request: Request) -> None: + """Drop this route's cached GETs after a request that may change data.""" + if request.method == HttpVerb.GET or self._config is None: + return + await run_in_threadpool( + RedisCacheBackend.shared().invalidate_prefix, self._key_prefix() + ) + def _cached_response(self, request: Request) -> Response | None: + cached = RedisCacheBackend.shared().get(self._key(request)) + return JSONResponse(cached) if cached is not None else None -def set_cached(key: str, value: Any, ttl: int) -> None: - """Store *value* under *key* for *ttl* seconds. Silently ignores errors.""" - _default_backend.set(key, value, ttl) + def _store_response(self, request: Request, response: Response) -> None: + if self._config is None or not ( + isinstance(response, JSONResponse) and response.status_code == HTTPStatus.OK + ): + return + body = json.loads(response.body) + RedisCacheBackend.shared().set(self._key(request), body, self._config.ttl) + def _key(self, request: Request) -> str: + query = str(request.query_params) + return f"lightapi:{self._endpoint_name}:{request.url.path}:{query}" -def invalidate_cache_prefix(prefix: str) -> None: - """Delete all keys that start with *prefix*. Silently ignores errors.""" - _default_backend.invalidate_prefix(prefix) + def _key_prefix(self) -> str: + return f"lightapi:{self._endpoint_name}:" class BaseCache: @@ -199,7 +197,7 @@ class BaseCache: By default, acts as a no-op cache (doesn't actually cache anything). """ - def get(self, key: str) -> Optional[Dict[str, Any]]: + def get(self, key: str) -> dict[str, Any] | None: """ Retrieve data from the cache. @@ -211,7 +209,7 @@ def get(self, key: str) -> Optional[Dict[str, Any]]: """ return None - def set(self, key: str, value: Dict[str, Any], timeout: int = 300) -> bool: + def set(self, key: str, value: dict[str, Any], timeout: int = 300) -> bool: """ Store data in the cache. @@ -245,7 +243,7 @@ def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0): """ self.client = redis.Redis(host=host, port=port, db=db) - def get(self, key: str) -> Optional[Dict[str, Any]]: + def get(self, key: str) -> dict[str, Any] | None: """ Retrieve data from the Redis cache. @@ -263,7 +261,7 @@ def get(self, key: str) -> Optional[Dict[str, Any]]: return None return None - def set(self, key: str, value: Dict[str, Any], timeout: int = 300) -> bool: + def set(self, key: str, value: dict[str, Any], timeout: int = 300) -> bool: """ Store data in the Redis cache. diff --git a/lightapi/cache_handler.py b/lightapi/cache_handler.py deleted file mode 100644 index 0a8a5f9..0000000 --- a/lightapi/cache_handler.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Cache handler for request/response caching.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Callable - -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from lightapi.cache import get_cached, invalidate_cache_prefix, set_cached -from lightapi.constants import HTTPStatus - -if TYPE_CHECKING: - from lightapi.config import Cache - - -class CacheHandler: - """Handler for HTTP cache operations. - - Provides methods for checking cached responses, storing responses in cache, - and invalidating cache entries. - """ - - def __init__(self, cache_config: "Cache | None" = None) -> None: - """Initialize the cache handler. - - Args: - cache_config: Cache configuration (ttl, vary_on) - """ - self._cache_config = cache_config - - @property - def is_enabled(self) -> bool: - """Check if caching is enabled.""" - return self._cache_config is not None - - def get_cached_response( - self, request: Request, key_fn: Callable[[], str] - ) -> JSONResponse | None: - """Get cached response if available. - - Args: - request: The HTTP request - key_fn: Function to generate cache key - - Returns: - Cached JSONResponse if found, None otherwise - """ - if not self.is_enabled: - return None - - key = key_fn() - try: - cached = get_cached(key) - except Exception: - cached = None - - if cached is not None: - return JSONResponse(cached) - return None - - def cache_response( - self, - request: Request, - response: Response, - key_fn: Callable[[], str], - ) -> None: - """Cache a successful response. - - Args: - request: The HTTP request - response: The response to cache - key_fn: Function to generate cache key - """ - if not self.is_enabled: - return - - if isinstance(response, JSONResponse) and response.status_code == HTTPStatus.OK: - key = key_fn() - try: - set_cached(key, json.loads(response.body), self._cache_config.ttl) - except Exception: - pass - - def invalidate(self, request: Request, prefix_fn: Callable[[], str]) -> None: - """Invalidate cache entries for a prefix. - - Args: - request: The HTTP request - prefix_fn: Function to generate cache key prefix - """ - if not self.is_enabled: - return - - if request.method == "GET": - return - - prefix = prefix_fn() - invalidate_cache_prefix(prefix) - - @staticmethod - def build_key(cls: type, request: Request) -> str: - """Build a cache key from endpoint class and request. - - Args: - cls: The endpoint class - request: The HTTP request - - Returns: - Cache key string - """ - query = str(request.query_params) - return f"lightapi:{cls.__name__}:{request.url.path}:{query}" - - @staticmethod - def build_prefix(cls: type) -> str: - """Build a cache key prefix from endpoint class. - - Args: - cls: The endpoint class - - Returns: - Cache key prefix string - """ - return f"lightapi:{cls.__name__}:" diff --git a/lightapi/cache_helper.py b/lightapi/cache_helper.py deleted file mode 100644 index f40b942..0000000 --- a/lightapi/cache_helper.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Cache helper functions for GET caching and cache invalidation.""" - -import json -from typing import Callable - -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from lightapi.cache import get_cached, invalidate_cache_prefix, set_cached - - -def maybe_cached(cls: type, request: Request, fn: Callable[[], Response]) -> Response: - """Serve from Redis cache (GET only) or call fn() and populate cache.""" - cache_cfg = cls._meta.get("cache") - if cache_cfg is None: - return fn() - - key = _cache_key(cls, request) - try: - cached = get_cached(key) - except Exception: - cached = None - if cached is not None: - return JSONResponse(cached) - response = fn() - if isinstance(response, JSONResponse) and response.status_code == 200: - try: - body = response.body - if hasattr(body, "decode"): - body = body.decode("utf-8") - set_cached(key, json.loads(body), cache_cfg.ttl) - except Exception: - pass - return response - - -def maybe_invalidate_cache(cls: type, request: Request) -> None: - """Invalidate cache entries after mutating requests.""" - if request.method == "GET": - return - cache_cfg = cls._meta.get("cache") - if cache_cfg is None: - return - - invalidate_cache_prefix(_cache_key_prefix(cls)) - - -def _cache_key(cls: type, request: Request) -> str: - query = str(request.query_params) - return f"lightapi:{cls.__name__}:{request.url.path}:{query}" - - -def _cache_key_prefix(cls: type) -> str: - return f"lightapi:{cls.__name__}:" diff --git a/lightapi/cache_service.py b/lightapi/cache_service.py deleted file mode 100644 index f962207..0000000 --- a/lightapi/cache_service.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Cache service for LightAPI. - -Extracts caching logic from LightApi class for better SRP. -""" - -from __future__ import annotations - -import logging -from typing import Any, Callable, Optional - -from starlette.requests import Request -from starlette.responses import Response - -logger = logging.getLogger(__name__) - - -class CacheService: - """Handles response caching for endpoints. - - Separates caching logic from the main LightApi class. - """ - - def __init__(self, redis_url: str = "redis://localhost:6379/0") -> None: - self._redis_url = redis_url - self._redis_client = None - self._enabled = False - - @property - def redis_url(self) -> str: - return self._redis_url - - @property - def is_enabled(self) -> bool: - return self._enabled - - def _get_redis(self) -> Any: - """Get Redis client, lazily initialized.""" - if self._redis_client is not None: - return self._redis_client - - try: - import redis - - self._redis_client = redis.from_url(self._redis_url) - self._enabled = True - return self._redis_client - except Exception as e: - logger.warning(f"Redis connection failed: {e}. Caching disabled.") - self._enabled = False - return None - - def get_cached(self, key: str) -> Optional[bytes]: - """Get cached value by key.""" - redis_client = self._get_redis() - if redis_client is None: - return None - - try: - return redis_client.get(key) - except Exception: - return None - - def set_cached(self, key: str, value: bytes, ttl: int) -> None: - """Set cached value with TTL.""" - redis_client = self._get_redis() - if redis_client is None: - return - - try: - redis_client.setex(key, ttl, value) - except Exception: - pass - - def invalidate_prefix(self, prefix: str) -> None: - """Invalidate all keys with given prefix.""" - redis_client = self._get_redis() - if redis_client is None: - return - - try: - pattern = f"{prefix}:*" - for key in redis_client.scan_iter(match=pattern): - redis_client.delete(key) - except Exception: - pass - - def maybe_wrap( - self, - request: Request, - cache_config: Optional[Any], - handler: Callable, - ) -> Response: - """Wrap handler with caching if configured. - - Args: - request: The HTTP request - cache_config: Cache configuration from endpoint Meta - handler: The handler function to wrap - - Returns: - Response (cached or fresh) - """ - if cache_config is None: - return handler() - - # Build cache key - cache_key = f"{request.url.path}:{request.query_params}" - - # Try to get cached response - cached = self.get_cached(cache_key) - if cached is not None: - import json - - try: - json.loads(cached) - return Response(content=cached, media_type="application/json") - except json.JSONDecodeError: - pass - - # Execute handler - response = handler() - - # Cache successful GET responses - if response.status_code == 200: - self.set_cached(cache_key, response.body, cache_config.ttl) - - return response - - def check_connections(self) -> bool: - """Check if Redis is available. - - Returns: - True if Redis is available, False otherwise - """ - redis_client = self._get_redis() - if redis_client is None: - return False - - try: - redis_client.ping() - return True - except Exception: - return False diff --git a/lightapi/config.py b/lightapi/config.py index 2f37ee3..3a43f4f 100644 --- a/lightapi/config.py +++ b/lightapi/config.py @@ -1,7 +1,9 @@ from __future__ import annotations import os +from collections.abc import Callable, Sequence from dataclasses import dataclass, field +from functools import cached_property from typing import TYPE_CHECKING, Any from lightapi.constants import ( @@ -9,12 +11,14 @@ DEFAULT_JWT_ALGORITHM, DEFAULT_JWT_EXPIRATION, DEFAULT_PAGE_SIZE, - VALID_JWT_ALGORITHMS, - VALID_PAGINATION_STYLES, + JwtAlgorithm, ) from lightapi.exceptions import ConfigurationError +from lightapi.pagination import PaginationStyle, Paginator if TYPE_CHECKING: + from lightapi.authentication.base import BaseAuthentication, BasePermission + from lightapi.filters import BaseFilter from lightapi.rate_limiter import RateLimiter @@ -37,24 +41,7 @@ def jwt_secret_value(self) -> str | None: @property def jwt_algorithm_value(self) -> str: - algorithm = self.jwt_algorithm or os.environ.get( - "LIGHTAPI_JWT_ALGORITHM", DEFAULT_JWT_ALGORITHM - ) - if algorithm not in VALID_JWT_ALGORITHMS: - raise ConfigurationError( - f"Invalid JWT algorithm '{algorithm}'. " - f"Valid algorithms are: {sorted(VALID_JWT_ALGORITHMS)}" - ) - return algorithm - - def update(self, **kwargs: Any) -> None: - """Update configuration values.""" - if "jwt_secret" in kwargs: - self.jwt_secret = kwargs["jwt_secret"] - if "jwt_algorithm" in kwargs: - self.jwt_algorithm = kwargs["jwt_algorithm"] - if "jwt_expiration" in kwargs: - self.jwt_expiration = kwargs["jwt_expiration"] + return JwtAlgorithm.validated(self.jwt_algorithm) # Default global config instance for backward compatibility @@ -65,50 +52,74 @@ def update(self, **kwargs: Any) -> None: class Authentication: """Authentication configuration for a RestEndpoint.""" - backend: type | None = None - permission: type | None = None + backend: type[BaseAuthentication] | None = None + # One permission class for every method, or {"GET": ..., "DELETE": ...}. + permission: type[BasePermission] | dict[str, type[BasePermission]] | None = None jwt_expiration: int | None = None - jwt_extra_claims: tuple[str, ...] = field(default_factory=tuple) + jwt_extra_claims: Sequence[str] | None = () jwt_algorithm: str | None = None - rate_limiter: "RateLimiter | None" = field(default=None, repr=False) - - def __post_init__(self) -> None: - # Lazy import to avoid circular imports - pass - - @property - def permission_value(self) -> type: - from lightapi.authentication import AllowAny - - return self.permission or AllowAny + rate_limiter: RateLimiter | None = field(default=None, repr=False) + + def permission_for(self, method: str) -> type[BasePermission] | None: + """Permission class set for an HTTP method; None when none is set for it.""" + if isinstance(self.permission, dict): + return self.permission.get(method) + return self.permission + + def requires_login(self, method: str) -> bool: + """Whether the backend has to authenticate a request with this method. + + A backend with no permission set still authenticates. A permission that + does not require authentication (AllowAny), for every method or for this + one, makes the request public. + """ + if self.backend is None: + return False + if self.permission is None: + return True + permission = self.permission_for(method) + return permission is not None and permission.requires_authentication + + def build_backend( + self, login_validator: Callable[[str, str], Any] | None = None + ) -> BaseAuthentication: + if self.backend is None: + raise ConfigurationError("Authentication has no backend to build.") + return self.backend.from_config(self, login_validator) @dataclass(frozen=True) class Filtering: """Filtering configuration for a RestEndpoint.""" - backends: tuple[type, ...] = field(default_factory=tuple) - fields: tuple[str, ...] = field(default_factory=tuple) - search: tuple[str, ...] = field(default_factory=tuple) - ordering: tuple[str, ...] = field(default_factory=tuple) + backends: Sequence[type[BaseFilter]] | None = () + fields: Sequence[str] | None = () + search: Sequence[str] | None = () + ordering: Sequence[str] | None = () @dataclass(frozen=True) class Pagination: """Pagination configuration for a RestEndpoint.""" - style: str = "page_number" + # A PaginationStyle or its value, as the user wrote it; `paginator` converts. + style: PaginationStyle | str = PaginationStyle.PAGE_NUMBER page_size: int = DEFAULT_PAGE_SIZE - def __post_init__(self): - if self.style not in VALID_PAGINATION_STYLES: + def __post_init__(self) -> None: + if self.style not in PaginationStyle.__members__.values(): raise ConfigurationError( f"Pagination style '{self.style}' is invalid. " - f"Choose from: {VALID_PAGINATION_STYLES}" + f"Choose from: {tuple(valid.value for valid in PaginationStyle)}" ) if self.page_size < 1: raise ConfigurationError("Pagination page_size must be a positive integer.") + @cached_property + def paginator(self) -> Paginator: + """Built once per configuration; paginators keep no per-request state.""" + return PaginationStyle(self.style).build(self.page_size) + @dataclass(frozen=True) class Cache: @@ -117,7 +128,7 @@ class Cache: ttl: int = DEFAULT_CACHE_TTL vary_on: tuple[str, ...] = field(default_factory=tuple) - def __post_init__(self): + def __post_init__(self) -> None: if self.ttl < 1: raise ConfigurationError("Cache ttl must be a positive integer (seconds).") @@ -137,7 +148,10 @@ class Serializer: read: list[str] | None = None write: list[str] | None = None - def __init_subclass__(cls, **kwargs: Any) -> None: + def __init_subclass__( + cls, + **kwargs: Any, # noqa: ANN401 class kwargs, forwarded + ) -> None: super().__init_subclass__(**kwargs) has_fields = cls.__dict__.get("fields") is not None has_read = cls.__dict__.get("read") is not None diff --git a/lightapi/constants.py b/lightapi/constants.py index ba46105..ac0375d 100644 --- a/lightapi/constants.py +++ b/lightapi/constants.py @@ -1,10 +1,53 @@ from enum import Enum +from http import HTTPStatus +from typing import Final + +from starlette.responses import JSONResponse + +from lightapi.exceptions import ConfigurationError +from lightapi.methods import HttpMethod + + +# ───────────────────────────────────────────────────────────────────────── +# Execution mode (sync Engine or AsyncEngine) +# ───────────────────────────────────────────────────────────────────────── +class ExecutionMode(str, Enum): + SYNC = "sync" + ASYNC = "async" + + @classmethod + def from_user(cls, mode: "str | None") -> "ExecutionMode": + """The mode named by LightApi(mode=...); 'sync' when none is given.""" + if mode is None: + return cls.SYNC + try: + return cls(mode) + except ValueError: + raise ConfigurationError( + f"mode must be 'sync' or 'async', got '{mode}'" + ) from None # ───────────────────────────────────────────────────────────────────────── -# HTTP Methods +# Response Keys # ───────────────────────────────────────────────────────────────────────── -class HttpMethod(str, Enum): +class ResponseKey(str, Enum): + DETAIL = "detail" + RESULTS = "results" + TOKEN = "token" # noqa: S105 a response key, not a secret + USER = "user" + COUNT = "count" + PAGES = "pages" + NEXT = "next" + PREVIOUS = "previous" + + +# ───────────────────────────────────────────────────────────────────────── +# HTTP verbs (lightapi.HttpMethod is the public marker namespace, not this) +# ───────────────────────────────────────────────────────────────────────── +class HttpVerb(str, Enum): + """A request method. Compare `request.method == HttpVerb.GET`; never format it.""" + GET = "GET" POST = "POST" PUT = "PUT" @@ -12,37 +55,93 @@ class HttpMethod(str, Enum): DELETE = "DELETE" OPTIONS = "OPTIONS" + @classmethod + def of(cls, method: str) -> "HttpVerb | None": + """The verb for a request method; None for one the API never serves (HEAD).""" + try: + return cls(method) + except ValueError: + return None + + +CRUD_VERBS: Final = frozenset( + {HttpVerb.GET, HttpVerb.POST, HttpVerb.PUT, HttpVerb.PATCH, HttpVerb.DELETE} +) + +# The public marker mixin of each CRUD verb, by the verb's name (YAML and dict configs). +HTTP_METHOD_MIXINS: Final[dict[str, type]] = { + HttpVerb.GET.value: HttpMethod.GET, + HttpVerb.POST.value: HttpMethod.POST, + HttpVerb.PUT.value: HttpMethod.PUT, + HttpVerb.PATCH.value: HttpMethod.PATCH, + HttpVerb.DELETE.value: HttpMethod.DELETE, +} + # ───────────────────────────────────────────────────────────────────────── -# HTTP Status Codes +# Error replies with a fixed text (status, detail, extra headers) # ───────────────────────────────────────────────────────────────────────── -class HTTPStatus(int, Enum): - OK = 200 - CREATED = 201 - NO_CONTENT = 204 - BAD_REQUEST = 400 - UNAUTHORIZED = 401 - FORBIDDEN = 403 - NOT_FOUND = 404 - METHOD_NOT_ALLOWED = 405 - CONFLICT = 409 - UNPROCESSABLE_ENTITY = 422 - TOO_MANY_REQUESTS = 429 +# The range of a BIGINT / SQLite INTEGER: a key or an integer filter value outside it +# never matches a row, and the drivers raise instead of comparing. +INT64_MIN: Final = -(2**63) +INT64_MAX: Final = 2**63 - 1 + + +class ApiError(Enum): + NOT_FOUND = (HTTPStatus.NOT_FOUND, "not found") + VERSION_CONFLICT = (HTTPStatus.CONFLICT, "version conflict") + CONSTRAINT_VIOLATION = (HTTPStatus.CONFLICT, "constraint violation") + INSUFFICIENT_PERMISSIONS = (HTTPStatus.FORBIDDEN, "Insufficient permissions") + INVALID_CREDENTIALS = (HTTPStatus.UNAUTHORIZED, "Invalid credentials") + + def __init__( + self, status: HTTPStatus, detail: str, headers: "dict[str, str] | None" = None + ) -> None: + self.status = status + self.detail = detail + self.headers = headers + + def response(self) -> JSONResponse: + return JSONResponse( + {ResponseKey.DETAIL: self.detail}, + status_code=self.status, + headers=self.headers, + ) + + @staticmethod + def validation_failed(errors: object) -> JSONResponse: + """The 422 reply; `errors` is pydantic's error list or one built like it.""" + return JSONResponse( + {ResponseKey.DETAIL: errors}, status_code=HTTPStatus.UNPROCESSABLE_ENTITY + ) # ───────────────────────────────────────────────────────────────────────── -# Response Keys +# Table reflection (Meta.reflect) # ───────────────────────────────────────────────────────────────────────── -RESPONSE_KEY_DETAIL = "detail" -RESPONSE_KEY_RESULTS = "results" -RESPONSE_KEY_ERROR = "error" -RESPONSE_KEY_TOKEN = "token" -RESPONSE_KEY_USER = "user" -RESPONSE_KEY_COUNT = "count" -RESPONSE_KEY_PAGES = "pages" -RESPONSE_KEY_NEXT = "next" -RESPONSE_KEY_PREVIOUS = "previous" -RESPONSE_KEY_ALLOWED_METHODS = "allowed_methods" +class TableReflection(str, Enum): + """Map the endpoint onto an existing table instead of generating one. + + PARTIAL also keeps the fields the endpoint declares itself. + """ + + FULL = "full" + PARTIAL = "partial" + + @classmethod + def from_meta(cls, value: object) -> "TableReflection | None": + """Meta.reflect takes True, False, "full" or "partial"; None: no reflection.""" + if value is None or value is False: + return None + if value is True: + return cls.FULL + try: + return cls(value) + except ValueError: + raise ConfigurationError( + f"Meta.reflect must be True, False, 'full' or 'partial', got {value!r}." + ) from None + # ───────────────────────────────────────────────────────────────────────── # Pagination @@ -50,47 +149,43 @@ class HTTPStatus(int, Enum): DEFAULT_PAGE_SIZE = 20 PAGE_PARAM = "page" CURSOR_PARAM = "cursor" -VALID_PAGINATION_STYLES = ("page_number", "cursor") # ───────────────────────────────────────────────────────────────────────── # Auto Fields (consolidated) # ───────────────────────────────────────────────────────────────────────── AUTO_FIELDS = frozenset({"id", "created_at", "updated_at", "version"}) + # ───────────────────────────────────────────────────────────────────────── # Auth / JWT # ───────────────────────────────────────────────────────────────────────── -DEFAULT_JWT_ALGORITHM = "HS256" -DEFAULT_JWT_EXPIRATION = 3600 # seconds -VALID_JWT_ALGORITHMS = frozenset( - { - "HS256", - "HS384", - "HS512", - "RS256", - "RS384", - "RS512", - "ES256", - "ES384", - "ES512", - } -) -RESERVED_JWT_CLAIMS = frozenset({"exp", "iat", "nbf", "iss", "sub", "aud", "jti"}) +class JwtAlgorithm(str, Enum): + """Signing algorithms PyJWT accepts here; the value is what PyJWT receives.""" -# ───────────────────────────────────────────────────────────────────────── -# Rate Limiting -# ───────────────────────────────────────────────────────────────────────── -DEFAULT_RATE_LIMIT_PER_MINUTE = 10 -DEFAULT_RATE_LIMIT_PER_HOUR = 100 -DEFAULT_RATE_LIMIT_PER_DAY = 1000 -CLEANUP_INTERVAL = 300 # seconds + HS256 = "HS256" + HS384 = "HS384" + HS512 = "HS512" + RS256 = "RS256" + RS384 = "RS384" + RS512 = "RS512" + ES256 = "ES256" + ES384 = "ES384" + ES512 = "ES512" -# ───────────────────────────────────────────────────────────────────────── -# Application Defaults -# ───────────────────────────────────────────────────────────────────────── -DEFAULT_MODE = "sync" -VALID_MODES = ("sync", "async") -DEFAULT_AUTH_PATH = "/auth" + @classmethod + def validated(cls, name: str) -> str: + """The name as PyJWT takes it; ConfigurationError for an unknown one.""" + try: + return cls(name).value + except ValueError: + raise ConfigurationError( + f"Invalid JWT algorithm '{name}'. " + f"Valid algorithms are: {sorted(valid.value for valid in cls)}" + ) from None + + +DEFAULT_JWT_ALGORITHM = JwtAlgorithm.HS256.value +DEFAULT_JWT_EXPIRATION = 3600 # seconds # ───────────────────────────────────────────────────────────────────────── # Cache diff --git a/lightapi/core.py b/lightapi/core.py index a571ff1..4b71144 100644 --- a/lightapi/core.py +++ b/lightapi/core.py @@ -3,10 +3,17 @@ from __future__ import annotations import json -from typing import Any, Dict +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Final from starlette.responses import JSONResponse +from lightapi.constants import HttpVerb + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response as StarletteResponse + class Response(JSONResponse): """ @@ -18,9 +25,9 @@ class Response(JSONResponse): def __init__( self, - content: Any = None, - status_code: int = 200, - headers: Dict | None = None, + content: Any = None, # noqa: ANN401 any JSON value + status_code: int = HTTPStatus.OK, + headers: dict[str, str] | None = None, media_type: str | None = None, content_type: str | None = None, ): @@ -52,7 +59,11 @@ class Middleware: and responses before they are returned to the client. """ - def process(self, request: Any, response: Any) -> Any: + def process( + self, + request: Request, # noqa: ARG002 override point + response: StarletteResponse | None, + ) -> StarletteResponse | None: """ Process a request or response. @@ -77,6 +88,14 @@ class CORSMiddleware(Middleware): Handles CORS preflight requests and adds appropriate headers to responses. """ + _DEFAULT_ALLOW_METHODS: Final = ( + HttpVerb.GET, + HttpVerb.POST, + HttpVerb.PUT, + HttpVerb.DELETE, + HttpVerb.OPTIONS, + ) + def __init__( self, allow_origins: list[str] | None = None, @@ -86,7 +105,7 @@ def __init__( if allow_origins is None: allow_origins = ["*"] if allow_methods is None: - allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allow_methods = [verb.value for verb in self._DEFAULT_ALLOW_METHODS] if allow_headers is None: allow_headers = ["Authorization", "Content-Type"] @@ -94,12 +113,14 @@ def __init__( self.allow_methods = allow_methods self.allow_headers = allow_headers - def process(self, request: Any, response: Any) -> Any: + def process( + self, request: Request, response: StarletteResponse | None + ) -> StarletteResponse | None: if response is None: - if request.method == "OPTIONS": + if request.method == HttpVerb.OPTIONS: return JSONResponse( {}, - status_code=200, + status_code=HTTPStatus.OK, headers={ "Access-Control-Allow-Origin": ", ".join(self.allow_origins), "Access-Control-Allow-Methods": ", ".join(self.allow_methods), @@ -140,10 +161,14 @@ def __init__(self, authentication_class: type | None = None): else: self.authenticator = None - def process(self, request: Any, response: Any) -> Any: + def process( + self, request: Request, response: StarletteResponse | None + ) -> StarletteResponse | None: if response is None and self.authenticator: if not self.authenticator.authenticate(request): - return JSONResponse({"error": "not allowed"}, status_code=403) + return JSONResponse( + {"error": "not allowed"}, status_code=HTTPStatus.FORBIDDEN + ) return None return response diff --git a/lightapi/crud_async.py b/lightapi/crud_async.py deleted file mode 100644 index ef4a8e1..0000000 --- a/lightapi/crud_async.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Asynchronous CRUD operations for endpoints.""" - -import datetime -from typing import Any - -from sqlalchemy import delete, update -from sqlalchemy import select as sa_select -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus -from lightapi.pagination import CursorPaginator, PageNumberPaginator -from lightapi.queryset import FilterRunner, QuerysetResolver, RowSerializer -from lightapi.session import get_async_session - -_AUTO_FIELDS = frozenset({"id", "created_at", "updated_at", "version"}) - - -class AsyncEngineResolver: - def get(self, endpoint: Any) -> Any: - """Get async engine for async session creation.""" - cls = type(endpoint) - session_manager = getattr(cls, "_session_manager", None) - if session_manager is None: - raise RuntimeError( - "No session_manager configured. " - "Ensure LightApi.register() was called with a properly configured app." - ) - return session_manager.engine - - -class AsyncCRUD: - def __init__(self) -> None: - self._engine_resolver = AsyncEngineResolver() - self._queryset_resolver = QuerysetResolver() - self._filter_runner = FilterRunner() - self._serializer = RowSerializer() - - async def list(self, endpoint: Any, request: Request) -> Response: - """Async: Handle GET /{path} — return collection.""" - engine = self._engine_resolver.get(endpoint) - pagination_cfg = endpoint._meta.get("pagination") - - async with get_async_session(engine) as session: - qs = await self._queryset_resolver.get_async(endpoint, request) - qs = self._filter_runner.run(endpoint, request, qs) - - if pagination_cfg: - if pagination_cfg.style == "cursor": - pager = CursorPaginator() - rows, next_cursor = await pager.paginate_async( - request, qs, session, pagination_cfg.page_size - ) - results = [ - self._serializer.serialize(endpoint, r, "GET") for r in rows - ] - return JSONResponse(pager.wrap(results, next_cursor, None)) - else: - pager = PageNumberPaginator() - page = int(request.query_params.get("page", 1)) - rows, total = await pager.paginate_async( - request, qs, session, pagination_cfg.page_size - ) - results = [ - self._serializer.serialize(endpoint, r, "GET") for r in rows - ] - return JSONResponse( - pager.wrap( - request, results, total, page, pagination_cfg.page_size - ) - ) - - instances = (await session.execute(qs)).scalars().all() - results = [ - self._serializer.serialize(endpoint, inst, "GET") for inst in instances - ] - return JSONResponse({RESPONSE_KEY_DETAIL: results}) - - async def retrieve(self, endpoint: Any, request: Request, pk: int) -> Response: - """Async: Handle GET /{path}/{id}.""" - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - async with get_async_session(engine) as session: - instance = ( - ( - await session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - ) - .scalars() - .first() - ) - if instance is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - return JSONResponse(self._serializer.serialize(endpoint, instance, "GET")) - - async def create(self, endpoint: Any, data: dict[str, Any]) -> Response: - """Async: Handle POST /{path} — validate input and insert row.""" - from pydantic import ValidationError - - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - try: - validated = cls.__schema_create__.model_validate(data) - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - async with get_async_session(engine) as session: - now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - instance = cls._model_class( - **validated.model_dump(), - created_at=now, - updated_at=now, - version=1, - ) - session.add(instance) - await session.flush() - await session.refresh(instance) - response_data = self._serializer.serialize(endpoint, instance, "POST") - return JSONResponse(response_data, status_code=HTTPStatus.CREATED) - - async def put(self, endpoint: Any, data: dict[str, Any], pk: int) -> Response: - """Async: Handle PUT /{path}/{id} with optimistic locking.""" - return await self._do_update(endpoint, data, pk, partial=False) - - async def patch(self, endpoint: Any, data: dict[str, Any], pk: int) -> Response: - """Async: Handle PATCH /{path}/{id} with optimistic locking.""" - return await self._do_update(endpoint, data, pk, partial=True) - - async def _do_update( - self, endpoint: Any, data: dict[str, Any], pk: int, partial: bool - ) -> Response: - """Internal async update logic for PUT/PATCH.""" - from typing import Optional as _Opt - - from pydantic import ConfigDict as _CD - from pydantic import ValidationError - from pydantic import create_model as _cm - - client_version = data.get("version") - if client_version is None: - return JSONResponse( - { - RESPONSE_KEY_DETAIL: [ - {"loc": ["version"], "msg": "Field required", "type": "missing"} - ] - }, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - - try: - if partial: - patch_fields: dict[str, Any] = {} - for fname, finfo in cls.__schema_create__.model_fields.items(): - ann = finfo.annotation - patch_fields[fname] = (_Opt[ann], None) - PatchSchema = _cm( - f"{cls.__name__}PatchSchema", - __config__=_CD(from_attributes=True), - **patch_fields, - ) - validated = PatchSchema.model_validate(data) - update_data = { - k: v - for k, v in validated.model_dump(exclude_unset=True).items() - if k not in _AUTO_FIELDS and v is not None - } - else: - validated = cls.__schema_create__.model_validate(data) - update_data = { - k: v - for k, v in validated.model_dump().items() - if k not in _AUTO_FIELDS - } - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - update_data.pop("version", None) - - async with get_async_session(engine) as session: - result = await session.execute( - update(cls._model_class) - .where( - cls._model_class.id == pk, - cls._model_class.version == client_version, - ) - .values( - **update_data, - version=client_version + 1, - updated_at=datetime.datetime.now(datetime.timezone.utc).replace( - tzinfo=None - ), - ) - ) - if result.rowcount == 0: - exists = await session.execute( - sa_select(cls._model_class.id).where(cls._model_class.id == pk) - ) - await session.rollback() - if not exists.first(): - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, - status_code=HTTPStatus.NOT_FOUND, - ) - return JSONResponse( - {RESPONSE_KEY_DETAIL: "version conflict"}, - status_code=HTTPStatus.CONFLICT, - ) - instance = ( - ( - await session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - ) - .scalars() - .first() - ) - response_data = self._serializer.serialize(endpoint, instance, "PUT") - return JSONResponse(response_data) - - async def destroy(self, endpoint: Any, request: Request, pk: int) -> Response: - """Async: Handle DELETE /{path}/{id}.""" - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - async with get_async_session(engine) as session: - stmt = ( - delete(cls._model_class) - .where(cls._model_class.id == pk) - .returning(cls._model_class.id) - ) - result = (await session.execute(stmt)).first() - if result is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - return Response(status_code=HTTPStatus.NO_CONTENT) - - -_crud_async = AsyncCRUD() - - -async def async_list(endpoint: Any, request: Request) -> Response: - return await _crud_async.list(endpoint, request) - - -async def async_retrieve(endpoint: Any, request: Request, pk: int) -> Response: - return await _crud_async.retrieve(endpoint, request, pk) - - -async def async_create(endpoint: Any, data: dict[str, Any]) -> Response: - return await _crud_async.create(endpoint, data) - - -async def async_put(endpoint: Any, data: dict[str, Any], pk: int) -> Response: - return await _crud_async.put(endpoint, data, pk) - - -async def async_patch(endpoint: Any, data: dict[str, Any], pk: int) -> Response: - return await _crud_async.patch(endpoint, data, pk) - - -async def async_destroy(endpoint: Any, request: Request, pk: int) -> Response: - return await _crud_async.destroy(endpoint, request, pk) diff --git a/lightapi/crud_sync.py b/lightapi/crud_sync.py deleted file mode 100644 index 83a5cc2..0000000 --- a/lightapi/crud_sync.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Synchronous CRUD operations for endpoints.""" - -import datetime -from typing import Any - -from sqlalchemy import delete, update -from sqlalchemy import select as sa_select -from sqlalchemy.orm import Session -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus -from lightapi.pagination import CursorPaginator, PageNumberPaginator -from lightapi.queryset import FilterRunner, QuerysetResolver, RowSerializer - -_AUTO_FIELDS = frozenset({"id", "created_at", "updated_at", "version"}) - - -class EngineResolver: - def get(self, endpoint: Any) -> Any: - """Get sync engine, unwrapping AsyncEngine if needed.""" - cls = type(endpoint) - session_manager = getattr(cls, "_session_manager", None) - if session_manager is None: - raise RuntimeError( - "No session_manager configured. " - "Ensure LightApi.register() was called with a properly configured app." - ) - engine = session_manager.engine - try: - from sqlalchemy.ext.asyncio import AsyncEngine as _AE - - if isinstance(engine, _AE): - return engine.sync_engine - except ImportError: - pass - return engine - - -class SyncCRUD: - def __init__(self) -> None: - self._engine_resolver = EngineResolver() - self._queryset_resolver = QuerysetResolver() - self._filter_runner = FilterRunner() - self._serializer = RowSerializer() - - def list(self, endpoint: Any, request: Request) -> Response: - """Handle GET /{path} — return collection.""" - engine = self._engine_resolver.get(endpoint) - pagination_cfg = endpoint._meta.get("pagination") - - with Session(engine) as session: - qs = self._queryset_resolver.get_sync(endpoint, request) - qs = self._filter_runner.run(endpoint, request, qs) - - if pagination_cfg: - if pagination_cfg.style == "cursor": - pager = CursorPaginator() - rows, next_cursor = pager.paginate( - request, qs, session, pagination_cfg.page_size - ) - results = [ - self._serializer.serialize(endpoint, r, "GET") for r in rows - ] - return JSONResponse(pager.wrap(results, next_cursor, None)) - else: - pager = PageNumberPaginator() - page = int(request.query_params.get("page", 1)) - rows, total = pager.paginate( - request, qs, session, pagination_cfg.page_size - ) - results = [ - self._serializer.serialize(endpoint, r, "GET") for r in rows - ] - return JSONResponse( - pager.wrap( - request, results, total, page, pagination_cfg.page_size - ) - ) - - instances = session.execute(qs).scalars().all() - results = [ - self._serializer.serialize(endpoint, inst, "GET") for inst in instances - ] - return JSONResponse({RESPONSE_KEY_DETAIL: results}) - - def retrieve(self, endpoint: Any, request: Request, pk: int) -> Response: - """Handle GET /{path}/{id}.""" - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - with Session(engine) as session: - instance = ( - session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - .scalars() - .first() - ) - if instance is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - return JSONResponse(self._serializer.serialize(endpoint, instance, "GET")) - - def create(self, endpoint: Any, data: dict[str, Any]) -> Response: - """Handle POST /{path} — validate input and insert row.""" - from pydantic import ValidationError - - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - try: - validated = cls.__schema_create__.model_validate(data) - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - with Session(engine) as session: - now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - instance = cls._model_class( - **validated.model_dump(), - created_at=now, - updated_at=now, - version=1, - ) - session.add(instance) - session.flush() - session.refresh(instance) - response_data = self._serializer.serialize(endpoint, instance, "POST") - session.commit() - return JSONResponse(response_data, status_code=HTTPStatus.CREATED) - - def put(self, endpoint: Any, data: dict[str, Any], pk: int) -> Response: - """Handle PUT /{path}/{id} with optimistic locking.""" - return self._do_update(endpoint, data, pk, partial=False) - - def patch(self, endpoint: Any, data: dict[str, Any], pk: int) -> Response: - """Handle PATCH /{path}/{id} with optimistic locking.""" - return self._do_update(endpoint, data, pk, partial=True) - - def _do_update( - self, endpoint: Any, data: dict[str, Any], pk: int, partial: bool - ) -> Response: - """Internal update logic for PUT/PATCH.""" - from typing import Optional as _Opt - - from pydantic import ConfigDict as _CD - from pydantic import ValidationError - from pydantic import create_model as _cm - - client_version = data.get("version") - if client_version is None: - return JSONResponse( - { - RESPONSE_KEY_DETAIL: [ - {"loc": ["version"], "msg": "Field required", "type": "missing"} - ] - }, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - - try: - if partial: - patch_fields: dict[str, Any] = {} - for fname, finfo in cls.__schema_create__.model_fields.items(): - ann = finfo.annotation - patch_fields[fname] = (_Opt[ann], None) - PatchSchema = _cm( - f"{cls.__name__}PatchSchema", - __config__=_CD(from_attributes=True), - **patch_fields, - ) - validated = PatchSchema.model_validate(data) - update_data = { - k: v - for k, v in validated.model_dump(exclude_unset=True).items() - if k not in _AUTO_FIELDS and v is not None - } - else: - validated = cls.__schema_create__.model_validate(data) - update_data = { - k: v - for k, v in validated.model_dump().items() - if k not in _AUTO_FIELDS - } - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - update_data.pop("version", None) - - with Session(engine) as session: - result = session.execute( - update(cls._model_class) - .where( - cls._model_class.id == pk, - cls._model_class.version == client_version, - ) - .values( - **update_data, - version=client_version + 1, - updated_at=datetime.datetime.now(datetime.timezone.utc).replace( - tzinfo=None - ), - ) - ) - if result.rowcount == 0: - exists = session.execute( - sa_select(cls._model_class.id).where(cls._model_class.id == pk) - ).first() - session.rollback() - if not exists: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, - status_code=HTTPStatus.NOT_FOUND, - ) - return JSONResponse( - {RESPONSE_KEY_DETAIL: "version conflict"}, - status_code=HTTPStatus.CONFLICT, - ) - instance = ( - session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - .scalars() - .first() - ) - response_data = self._serializer.serialize(endpoint, instance, "PUT") - session.commit() - return JSONResponse(response_data) - - def destroy(self, endpoint: Any, request: Request, pk: int) -> Response: - """Handle DELETE /{path}/{id}.""" - engine = self._engine_resolver.get(endpoint) - cls = type(endpoint) - with Session(engine) as session: - stmt = ( - delete(cls._model_class) - .where(cls._model_class.id == pk) - .returning(cls._model_class.id) - ) - result = session.execute(stmt).first() - if result is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - session.commit() - return Response(status_code=HTTPStatus.NO_CONTENT) - - -_crud_sync = SyncCRUD() - - -def sync_list(endpoint: Any, request: Request) -> Response: - return _crud_sync.list(endpoint, request) - - -def sync_retrieve(endpoint: Any, request: Request, pk: int) -> Response: - return _crud_sync.retrieve(endpoint, request, pk) - - -def sync_create(endpoint: Any, data: dict[str, Any]) -> Response: - return _crud_sync.create(endpoint, data) - - -def sync_put(endpoint: Any, data: dict[str, Any], pk: int) -> Response: - return _crud_sync.put(endpoint, data, pk) - - -def sync_patch(endpoint: Any, data: dict[str, Any], pk: int) -> Response: - return _crud_sync.patch(endpoint, data, pk) - - -def sync_destroy(endpoint: Any, request: Request, pk: int) -> Response: - return _crud_sync.destroy(endpoint, request, pk) diff --git a/lightapi/endpoint_handler.py b/lightapi/endpoint_handler.py new file mode 100644 index 0000000..2a5453d --- /dev/null +++ b/lightapi/endpoint_handler.py @@ -0,0 +1,361 @@ +"""Request handling for one RestEndpoint route: middlewares, guard, verb dispatch.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from enum import Enum, auto +from functools import cached_property +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Final + +from starlette.background import BackgroundTasks +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from lightapi.cache import EndpointCache +from lightapi.constants import ApiError, ExecutionMode, HttpVerb, ResponseKey +from lightapi.rest import RestEndpoint + +if TYPE_CHECKING: + from lightapi.authentication.base import BaseAuthentication, LoginValidator + from lightapi.config import Authentication + from lightapi.core import Middleware + + +@dataclass(frozen=True) +class MiddlewareChain: + """The app's middleware classes, run before and after every request. + + Each class is instantiated per call, as `Middleware` subclasses have always + been, so one with state cannot leak it across requests. + """ + + classes: list[type[Middleware]] + + @cached_property + def _awaits(self) -> dict[type[Middleware], bool]: + """Whether each class's process() is a coroutine function; decided once.""" + return {cls: asyncio.iscoroutinefunction(cls.process) for cls in self.classes} + + async def before(self, request: Request) -> Response | None: + """The first response a middleware answers with, or None to carry on.""" + for middleware_cls in self.classes: + result = await self._process(middleware_cls, request, None) + if result is not None: + return result + return None + + async def after(self, request: Request, response: Response) -> Response: + """The response after every middleware saw it, last registered first.""" + for middleware_cls in reversed(self.classes): + result = await self._process(middleware_cls, request, response) + if result is not None: + response = result + return response + + async def _process( + self, middleware_cls: type, request: Request, response: Response | None + ) -> Response | None: + middleware = middleware_cls() + if self._awaits[middleware_cls]: + awaited: Response | None = await middleware.process(request, response) + return awaited + result: Response | None = middleware.process(request, response) + return result + + +@dataclass(frozen=True) +class AppContext: + """What every handler of one LightApi app shares.""" + + middlewares: MiddlewareChain + mode: ExecutionMode + login_validator: LoginValidator | None = None + + +class EndpointGuard: + """Decides whether a request may reach an endpoint with an Authentication.""" + + def __init__( + self, + authentication: Authentication, + login_validator: LoginValidator | None = None, + ) -> None: + self._authentication = authentication + self._login_validator = login_validator + + @cached_property + def backend(self) -> BaseAuthentication: + """Built on the first request that needs it, then shared by every later one.""" + return self._authentication.build_backend(self._login_validator) + + def check(self, request: Request) -> Response | None: + """The 401 or 403 response that stops the request, or None to let it in.""" + authentication = self._authentication + if authentication.requires_login(request.method) and not ( + self.backend.authenticate(request) + ): + # The backend's own 401: its text, and the challenge header its scheme + # needs (Bearer, or Basic with a realm so browsers prompt). + return self.backend.get_auth_error_response(request) + + permission = authentication.permission_for(request.method) + if permission is not None and not permission().has_permission(request): + return ApiError.INSUFFICIENT_PERMISSIONS.response() + + return None + + +class _CrudArgument(Enum): + """One positional argument of a built-in CRUD method.""" + + REQUEST = auto() + DATA = auto() + PK = auto() + FULL_UPDATE = auto() + PARTIAL_UPDATE = auto() + + +@dataclass(frozen=True) +class _Verb: + """How one HTTP method is served when the user did not override it.""" + + override: str + sync_name: str + async_name: str + arguments: tuple[_CrudArgument, ...] + reads_body: bool = False + cached: bool = False + + def call_arguments( + self, request: Request, data: dict[str, Any], pk: int | None + ) -> tuple[Any, ...]: + values: dict[_CrudArgument, Any] = { + _CrudArgument.REQUEST: request, + _CrudArgument.DATA: data, + _CrudArgument.PK: pk, + _CrudArgument.FULL_UPDATE: False, + _CrudArgument.PARTIAL_UPDATE: True, + } + return tuple(values[argument] for argument in self.arguments) + + +_COLLECTION_VERBS: Final[dict[HttpVerb, _Verb]] = { + HttpVerb.GET: _Verb( + "get", "list", "_list_async", (_CrudArgument.REQUEST,), cached=True + ), + HttpVerb.POST: _Verb( + "post", "create", "_create_async", (_CrudArgument.DATA,), reads_body=True + ), +} + +_DETAIL_VERBS: Final[dict[HttpVerb, _Verb]] = { + HttpVerb.GET: _Verb( + "get", + "retrieve", + "_retrieve_async", + (_CrudArgument.REQUEST, _CrudArgument.PK), + cached=True, + ), + HttpVerb.PUT: _Verb( + "put", + "update", + "_update_async", + (_CrudArgument.DATA, _CrudArgument.PK, _CrudArgument.FULL_UPDATE), + reads_body=True, + ), + HttpVerb.PATCH: _Verb( + "patch", + "update", + "_update_async", + (_CrudArgument.DATA, _CrudArgument.PK, _CrudArgument.PARTIAL_UPDATE), + reads_body=True, + ), + HttpVerb.DELETE: _Verb( + "delete", + "destroy", + "_destroy_async", + (_CrudArgument.REQUEST, _CrudArgument.PK), + ), +} + + +@dataclass(frozen=True) +class _Override: + """A verb method the user wrote on the endpoint (`def get(self, request)`).""" + + fn: Callable[..., Any] + awaits: bool + + +@dataclass(frozen=True) +class _BoundVerb: + """A verb resolved against one endpoint class and execution mode, at register().""" + + verb: _Verb + crud: Callable[..., Any] # the class's built-in CRUD function, unbound + override: _Override | None + + +class EndpointHandler: + """Serves every request of one route (collection or detail) of an endpoint.""" + + def __init__( + self, + endpoint_cls: type[RestEndpoint], + verbs: dict[HttpVerb, _Verb], + app: AppContext, + ) -> None: + self._endpoint_cls = endpoint_cls + self._verbs = verbs + self._middlewares = app.middlewares + self._mode = app.mode + # _meta is the endpoint's parsed Meta; the framework reads it, users set it. + meta = endpoint_cls._meta # noqa: SLF001 + self._cache = EndpointCache(endpoint_cls.__name__, meta.get("cache")) + authentication = meta.get("authentication") + self._guard = ( + EndpointGuard(authentication, app.login_validator) + if authentication is not None + else None + ) + # Only the verbs the endpoint allows are bound; the others get the JSON 405. + self._bound = { + http_verb: self._bind(verb) + for http_verb, verb in verbs.items() + if http_verb.value in self.methods + } + + def _bind(self, verb: _Verb) -> _BoundVerb: + """Resolve what serves one verb on this endpoint class, once.""" + # A column may share a verb's name (`post: str`), so only functions count. + candidate = getattr(self._endpoint_cls, verb.override, None) + override = ( + _Override(candidate, asyncio.iscoroutinefunction(candidate)) + if inspect.isfunction(candidate) + else None + ) + crud_name = ( + verb.async_name if self._mode is ExecutionMode.ASYNC else verb.sync_name + ) + return _BoundVerb(verb, getattr(self._endpoint_cls, crud_name), override) + + @classmethod + def for_collection( + cls, endpoint_cls: type[RestEndpoint], app: AppContext + ) -> EndpointHandler: + return cls(endpoint_cls, _COLLECTION_VERBS, app) + + @classmethod + def for_detail( + cls, endpoint_cls: type[RestEndpoint], app: AppContext + ) -> EndpointHandler: + return cls(endpoint_cls, _DETAIL_VERBS, app) + + @property + def methods(self) -> list[str]: + """HTTP methods of this route that the endpoint allows.""" + allowed = self._endpoint_cls._allowed_methods # noqa: SLF001 metaclass-set + return [verb.value for verb in self._verbs if verb.value in allowed] + + async def handle(self, request: Request) -> Response: + endpoint = self._endpoint_cls() + # Per-request state the handler owns on the endpoint instance. + endpoint._background = BackgroundTasks() # noqa: SLF001 + endpoint._current_request = request # noqa: SLF001 + + early = await self._middlewares.before(request) + if early is not None: + return early + + if self._guard is not None: + denied = self._guard.check(request) + if denied is not None: + return denied + + http_verb = HttpVerb.of(request.method) + bound = self._bound.get(http_verb) if http_verb is not None else None + if bound is None: + result = self._method_not_allowed() + else: + result = await self._serve(bound, endpoint, request) + + response = JSONResponse(result) if isinstance(result, dict) else result + await self._cache.invalidate_after_write(request) + + background = endpoint._background # noqa: SLF001 set above + if background.tasks: + response.background = background + + return await self._middlewares.after(request, response) + + async def _serve( + self, bound: _BoundVerb, endpoint: RestEndpoint, request: Request + ) -> Any: # noqa: ANN401 a Response, or the dict an override returned + if bound.override is not None: + return await self._call_override(bound.override, endpoint, request) + + verb = bound.verb + data = await self._json_body(request) if verb.reads_body else {} + args = verb.call_arguments(request, data, request.path_params.get("id")) + + if self._mode is ExecutionMode.ASYNC: + if verb.cached: + return await self._cache.serve_async( + request, lambda: bound.crud(endpoint, *args) + ) + return await bound.crud(endpoint, *args) + + sessions = endpoint._session_manager_or_raise() # noqa: SLF001 plumbing + if verb.cached: + return await sessions.run_blocking( + self._cache.serve, request, lambda: bound.crud(endpoint, *args) + ) + return await sessions.run_blocking(bound.crud, endpoint, *args) + + async def _call_override( + self, override: _Override, endpoint: RestEndpoint, request: Request + ) -> Any: # noqa: ANN401 whatever the override returns + if override.awaits: + return await override.fn(endpoint, request) + if self._mode is ExecutionMode.ASYNC: + # Gives a sync override the greenlet context that endpoint.list() and + # friends need to reach an async driver. + return await endpoint._in_async_session( # noqa: SLF001 endpoint plumbing + lambda _session: override.fn(endpoint, request) + ) + sessions = endpoint._session_manager_or_raise() # noqa: SLF001 plumbing + return await sessions.run_blocking(override.fn, endpoint, request) + + @staticmethod + async def _json_body(request: Request) -> dict[str, Any]: + """The JSON body as sent; {} when empty or not JSON, so validation reports it. + + Anything else (a dropped connection, a consumed stream) propagates. + """ + try: + body: dict[str, Any] = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + return body + + def _method_not_allowed(self) -> Response: + return self.method_not_allowed(self.methods) + + @staticmethod + def method_not_allowed(methods: Iterable[str]) -> JSONResponse: + """The documented JSON 405: ``Allow`` lists the CRUD verbs, sorted. + + HEAD is left out: Starlette serves it on every GET route, and the README + documents the Allow value as the verbs the endpoint declares. + """ + allowed = ", ".join(sorted(m for m in methods if m != "HEAD")) + return JSONResponse( + {ResponseKey.DETAIL: f"Method Not Allowed. Allowed: {allowed}"}, + status_code=HTTPStatus.METHOD_NOT_ALLOWED, + headers={"Allow": allowed}, + ) diff --git a/lightapi/exceptions.py b/lightapi/exceptions.py index 6247d90..80b4164 100644 --- a/lightapi/exceptions.py +++ b/lightapi/exceptions.py @@ -4,30 +4,3 @@ class ConfigurationError(Exception): class SerializationError(Exception): """Raised when a database row cannot be converted to a serializable dict.""" - - -class MissingHandlerImplementationError(Exception): - """ - Exception raised when a required HTTP handler is not implemented. - - This exception is raised when a subclass of a handler class does not implement - a method that is required to handle a specific HTTP verb. - - Attributes: - handler_name: The name of the handler that should be implemented. - verb: The HTTP verb that requires the handler. - """ - - def __init__(self, handler_name: str, verb: str) -> None: - """ - Initialize the exception. - - Args: - handler_name: The name of the handler that should be implemented. - verb: The HTTP verb that requires the handler. - """ - msg = ( - f"Missing implementation for {handler_name} required for HTTP verb: " - f"{verb}. Please implement this handler in the subclass." - ) - super().__init__(msg) diff --git a/lightapi/fields.py b/lightapi/fields.py index 94c2b86..977dd56 100644 --- a/lightapi/fields.py +++ b/lightapi/fields.py @@ -1,12 +1,15 @@ +from typing import Any + from pydantic import Field as _pydantic_Field -from pydantic.fields import FieldInfo _LIGHTAPI_KWARGS: frozenset[str] = frozenset( {"foreign_key", "unique", "index", "exclude", "decimal_places"} ) -def Field(**kwargs: object) -> FieldInfo: # type: ignore[return] +def Field( + **kwargs: Any, # noqa: ANN401 pydantic Field kwargs, forwarded as given +) -> Any: # noqa: ANN401 typed like pydantic.Field, so `x: int = Field()` type-checks """LightAPI Field wrapper. Accepts all standard Pydantic Field kwargs plus LightAPI-specific column kwargs @@ -14,12 +17,12 @@ def Field(**kwargs: object) -> FieldInfo: # type: ignore[return] stored in json_schema_extra so RestEndpointMeta can read them; they are stripped before Pydantic processes the field definition. """ - lightapi_meta: dict[str, object] = { - k: kwargs.pop(k) # type: ignore[misc] - for k in list(kwargs) - if k in _LIGHTAPI_KWARGS - } - existing_extra: dict[str, object] = kwargs.pop("json_schema_extra", None) or {} # type: ignore[assignment] + lightapi_meta = {k: kwargs.pop(k) for k in list(kwargs) if k in _LIGHTAPI_KWARGS} + if "decimal_places" in lightapi_meta: + # Pydantic enforces the places at the edge: Numeric(scale=N) alone renders + # as plain NUMERIC on every dialect, so the column cannot. + kwargs["decimal_places"] = lightapi_meta["decimal_places"] + existing_extra: dict[str, Any] = kwargs.pop("json_schema_extra", None) or {} return _pydantic_Field( **kwargs, json_schema_extra={**existing_extra, **lightapi_meta} - ) # type: ignore[return-value] + ) diff --git a/lightapi/filters.py b/lightapi/filters.py index 7b93075..f82df86 100644 --- a/lightapi/filters.py +++ b/lightapi/filters.py @@ -1,21 +1,35 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any -from sqlalchemy import asc, desc +from sqlalchemy import Boolean, Float, Integer, Numeric, Select, asc, desc, or_ from starlette.requests import Request -_RESERVED_PARAMS = frozenset({"page", "page_size", "cursor", "ordering"}) -_LIKE_ESCAPE_CHAR = "\\" +from lightapi.constants import INT64_MAX, INT64_MIN +if TYPE_CHECKING: + from lightapi.rest import RestEndpoint -def _escape_like(value: str) -> str: - """Escape LIKE special characters so user input is treated as a literal string.""" - return ( - value.replace(_LIKE_ESCAPE_CHAR, _LIKE_ESCAPE_CHAR * 2) - .replace("%", _LIKE_ESCAPE_CHAR + "%") - .replace("_", _LIKE_ESCAPE_CHAR + "_") - ) + +class FilterValueError(ValueError): + """A query-string value the column type cannot parse (``?pages=lots``).""" + + def __init__(self, param: str, value: str, expected: str) -> None: + super().__init__(f"{param}={value!r} is not a valid {expected}") + self.param = param + self.value = value + self.expected = expected + + def errors(self) -> list[dict[str, Any]]: + """The 422 body, shaped like pydantic's error list.""" + return [ + { + "loc": ["query", self.param], + "msg": f"Input should be a valid {self.expected}", + "type": f"{self.expected}_parsing", + "input": self.value, + } + ] class BaseFilter: @@ -25,35 +39,87 @@ class BaseFilter: which receives a Select statement and should return a Select statement. """ - def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: - return queryset + RESERVED_PARAMS = frozenset({"page", "page_size", "cursor", "ordering"}) + LIKE_ESCAPE_CHAR = "\\" + def filter_queryset( + self, + request: Request, # noqa: ARG002 interface method + queryset: Select[Any], + view: RestEndpoint, # noqa: ARG002 interface method + ) -> Select[Any]: + return queryset -def _coerce_filter_value(col: Any, value: str) -> Any: - """Coerce a query-string value to match the column's Python type.""" - try: - from sqlalchemy import Boolean, Float, Integer, Numeric + @classmethod + def _escape_like(cls, value: str) -> str: + """Escape LIKE special characters so user input is a literal string.""" + char = cls.LIKE_ESCAPE_CHAR + return ( + value.replace(char, char * 2) + .replace("%", char + "%") + .replace("_", char + "_") + ) - col_type = col.property.columns[0].type if hasattr(col, "property") else None + @staticmethod + def _coerce_filter_value( + col: Any, # noqa: ANN401 a mapped attribute, or whatever a custom backend passes + value: str, + ) -> bool | int | float | str: + """Coerce a query-string value to match the column's Python type. + + An attribute that is not a column passes the value through as the string + it came in as. A value an int or float column cannot parse raises + FilterValueError: handed to the database it is a silent empty list on + SQLite and a DataError (500) on Postgres. + """ + try: + col_type = ( + col.property.columns[0].type if hasattr(col, "property") else None + ) + except (AttributeError, IndexError): + col_type = None if col_type is None: # InstrumentedAttribute from mapped class col_type = getattr(col, "type", None) if isinstance(col_type, Boolean): return value.lower() in ("1", "true", "yes", "on") - if isinstance(col_type, Integer): - return int(value) - if isinstance(col_type, (Numeric, Float)): - return float(value) - except Exception: - pass - return value + expected = "integer" if isinstance(col_type, Integer) else "number" + try: + if isinstance(col_type, Integer): + number = int(value) + elif isinstance(col_type, (Numeric, Float)): + return float(value) + else: + return value + except ValueError: + raise FilterValueError(getattr(col, "key", "?"), value, expected) from None + # Past 64 bits the driver raises (OverflowError on SQLite) instead of + # comparing; no column holds such a value. + if not INT64_MIN <= number <= INT64_MAX: + raise FilterValueError(getattr(col, "key", "?"), value, expected) + return number + + +def _coerce_filter_value( + col: Any, # noqa: ANN401 see BaseFilter._coerce_filter_value + value: str, +) -> bool | int | float | str: + """Module-level alias of BaseFilter._coerce_filter_value. + + docs/api-reference/filters.md teaches custom backends to import this + name directly; keep it importing here even though subclasses now get + it for free via self._coerce_filter_value. + """ + return BaseFilter._coerce_filter_value(col, value) # noqa: SLF001 the alias above class FieldFilter(BaseFilter): """Exact-match filter on whitelisted fields declared in Meta.filtering.fields.""" - def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: - filtering_cfg = getattr(view, "_meta", {}).get("filtering") + def filter_queryset( + self, request: Request, queryset: Select[Any], view: RestEndpoint + ) -> Select[Any]: + filtering_cfg = view._meta.get("filtering") # noqa: SLF001 the endpoint's Meta allowed_fields: list[str] = ( (filtering_cfg.fields or []) if filtering_cfg else [] ) @@ -62,11 +128,11 @@ def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: cls = type(view) for param, value in request.query_params.items(): - if param in _RESERVED_PARAMS or param not in allowed_fields: + if param in self.RESERVED_PARAMS or param not in allowed_fields: continue col = getattr(cls._model_class, param, None) if col is not None: - coerced = _coerce_filter_value(col, value) + coerced = self._coerce_filter_value(col, value) queryset = queryset.where(col == coerced) return queryset @@ -74,25 +140,27 @@ def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: class SearchFilter(BaseFilter): """Case-insensitive LIKE search across Meta.filtering.search fields.""" - def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: + def filter_queryset( + self, request: Request, queryset: Select[Any], view: RestEndpoint + ) -> Select[Any]: query = request.query_params.get("search") if not query: return queryset - filtering_cfg = getattr(view, "_meta", {}).get("filtering") + filtering_cfg = view._meta.get("filtering") # noqa: SLF001 the endpoint's Meta search_fields: list[str] = (filtering_cfg.search or []) if filtering_cfg else [] if not search_fields: return queryset - from sqlalchemy import or_ - cls = type(view) clauses = [] for field in search_fields: col = getattr(cls._model_class, field, None) if col is not None: clauses.append( - col.ilike(f"%{_escape_like(query)}%", escape=_LIKE_ESCAPE_CHAR) + col.ilike( + f"%{self._escape_like(query)}%", escape=self.LIKE_ESCAPE_CHAR + ) ) if clauses: queryset = queryset.where(or_(*clauses)) @@ -102,12 +170,14 @@ def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: class OrderingFilter(BaseFilter): """Ordering via ``?ordering=field`` or ``?ordering=-field`` (descending).""" - def filter_queryset(self, request: Request, queryset: Any, view: Any) -> Any: + def filter_queryset( + self, request: Request, queryset: Select[Any], view: RestEndpoint + ) -> Select[Any]: ordering_param = request.query_params.get("ordering") if not ordering_param: return queryset - filtering_cfg = getattr(view, "_meta", {}).get("filtering") + filtering_cfg = view._meta.get("filtering") # noqa: SLF001 the endpoint's Meta allowed: list[str] = (filtering_cfg.ordering or []) if filtering_cfg else [] # No whitelist configured → ordering is disabled entirely. diff --git a/lightapi/handler_factory.py b/lightapi/handler_factory.py deleted file mode 100644 index bc0bf29..0000000 --- a/lightapi/handler_factory.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Handler factory for creating Starlette route handlers.""" - -import asyncio -from typing import Any - -from starlette.background import BackgroundTasks -from starlette.requests import Request -from starlette.responses import Response - -from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus -from lightapi.rest import RestEndpoint - - -def make_collection_handler( - cls: type[RestEndpoint], - middlewares: list[type], - is_async: bool, -) -> Any: - """Create collection endpoint handler (list/create).""" - - async def handler(request: Request) -> Response: - from lightapi.auth_checker import check_auth - from lightapi.body_reader import read_body - from lightapi.middleware_runner import run_post_middlewares, run_pre_middlewares - from lightapi.response_wrapper import wrap_dict_response - - endpoint = cls() - endpoint._background = BackgroundTasks() - endpoint._current_request = request - - pre_result = await run_pre_middlewares(middlewares, request) - if pre_result is not None: - return pre_result - - auth_result = check_auth(cls, request) - if auth_result is not None: - return auth_result - - if request.method == "GET": - get_override = getattr(cls, "get", None) - if get_override and asyncio.iscoroutinefunction(get_override): - result = await get_override(endpoint, request) - elif is_async: - result = await endpoint._list_async(request) - else: - from lightapi.cache_helper import maybe_cached - - result = maybe_cached(cls, request, lambda: endpoint.list(request)) - elif request.method == "POST": - data = await read_body(request) - post_override = getattr(cls, "post", None) - if post_override and asyncio.iscoroutinefunction(post_override): - result = await post_override(endpoint, request) - elif is_async: - result = await endpoint._create_async(data) - else: - result = endpoint.create(data) - else: - allowed = ", ".join(sorted(cls._allowed_methods & {"GET", "POST"})) - result = __import__( - "starlette.responses", fromlist=["JSONResponse"] - ).JSONResponse( - {RESPONSE_KEY_DETAIL: f"Method Not Allowed. Allowed: {allowed}"}, - status_code=HTTPStatus.METHOD_NOT_ALLOWED, - headers={"Allow": allowed}, - ) - - response = wrap_dict_response(result) - - if not is_async: - from lightapi.cache_helper import maybe_invalidate_cache - - maybe_invalidate_cache(cls, request) - - if endpoint._background.tasks: - response.background = endpoint._background - - return await run_post_middlewares(middlewares, request, response) - - handler.__name__ = f"{cls.__name__}_collection" - handler.__endpoint_cls__ = cls - return handler - - -def make_detail_handler( - cls: type[RestEndpoint], - middlewares: list[type], - is_async: bool, -) -> Any: - """Create detail endpoint handler (retrieve/update/delete).""" - - async def handler(request: Request) -> Response: - from lightapi.auth_checker import check_auth - from lightapi.body_reader import read_body - from lightapi.middleware_runner import run_post_middlewares, run_pre_middlewares - from lightapi.response_wrapper import wrap_dict_response - - pk: int = request.path_params["id"] - endpoint = cls() - endpoint._background = BackgroundTasks() - endpoint._current_request = request - - pre_result = await run_pre_middlewares(middlewares, request) - if pre_result is not None: - return pre_result - - auth_result = check_auth(cls, request) - if auth_result is not None: - return auth_result - - if request.method == "GET": - get_override = getattr(cls, "get", None) - if get_override and asyncio.iscoroutinefunction(get_override): - result = await get_override(endpoint, request) - elif is_async: - result = await endpoint._retrieve_async(request, pk) - else: - from lightapi.cache_helper import maybe_cached - - result = maybe_cached( - cls, request, lambda: endpoint.retrieve(request, pk) - ) - elif request.method in {"PUT", "PATCH"}: - data = await read_body(request) - partial = request.method == "PATCH" - put_override = getattr(cls, "put" if not partial else "patch", None) - if put_override and asyncio.iscoroutinefunction(put_override): - result = await put_override(endpoint, request) - elif is_async: - result = await endpoint._update_async(data, pk, partial=partial) - else: - result = endpoint.update(data, pk, partial=partial) - elif request.method == "DELETE": - delete_override = getattr(cls, "delete", None) - if delete_override and asyncio.iscoroutinefunction(delete_override): - result = await delete_override(endpoint, request) - elif is_async: - result = await endpoint._destroy_async(request, pk) - else: - result = endpoint.destroy(request, pk) - else: - allowed = ", ".join( - sorted(cls._allowed_methods & {"GET", "PUT", "PATCH", "DELETE"}) - ) - result = __import__( - "starlette.responses", fromlist=["JSONResponse"] - ).JSONResponse( - {RESPONSE_KEY_DETAIL: f"Method Not Allowed. Allowed: {allowed}"}, - status_code=HTTPStatus.METHOD_NOT_ALLOWED, - headers={"Allow": allowed}, - ) - - response = wrap_dict_response(result) - - if not is_async: - from lightapi.cache_helper import maybe_invalidate_cache - - maybe_invalidate_cache(cls, request) - - if endpoint._background.tasks: - response.background = endpoint._background - - return await run_post_middlewares(middlewares, request, response) - - handler.__name__ = f"{cls.__name__}_detail" - handler.__endpoint_cls__ = cls - return handler diff --git a/lightapi/health.py b/lightapi/health.py index 24b4770..777035b 100644 --- a/lightapi/health.py +++ b/lightapi/health.py @@ -17,5 +17,8 @@ class HealthCheckEndpoint(HTTPEndpoint): touching a business endpoint. Returns ``200`` with a small JSON body. """ - async def get(self, request: Request) -> JSONResponse: + async def get( + self, + request: Request, # noqa: ARG002 HTTPEndpoint signature + ) -> JSONResponse: return JSONResponse({"status": "ok"}) diff --git a/lightapi/http_dispatcher.py b/lightapi/http_dispatcher.py deleted file mode 100644 index c4a7f3a..0000000 --- a/lightapi/http_dispatcher.py +++ /dev/null @@ -1,145 +0,0 @@ -"""HTTP method dispatch for LightAPI. - -Provides class-based HTTP method handling using dispatch pattern. -""" - -from typing import Any, Protocol - -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from lightapi.constants import RESPONSE_KEY_ALLOWED_METHODS, HTTPStatus - - -class HttpMethodHandler(Protocol): - """Protocol for HTTP method handlers.""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - """Handle the HTTP request.""" - ... - - def get_options_response(self, endpoint: Any, request: Request) -> Response: - """Get the OPTIONS response for allowed methods.""" - ... - - -class OptionsHandler: - """Handler for OPTIONS requests.""" - - def get_options_response(self, endpoint: Any, request: Request) -> JSONResponse: - cls = type(endpoint) - allowed = ", ".join(sorted(cls._allowed_methods)) - return JSONResponse( - {RESPONSE_KEY_ALLOWED_METHODS: allowed.split(", ")}, - status_code=HTTPStatus.OK, - ) - - -class GetHandler(HttpMethodHandler): - """Handler for GET requests (list/retrieve).""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - pk = request.path_params.get("id") - if pk is not None: - return await endpoint._retrieve_async(request, pk) - return await endpoint._list_async(request) - - -class GetSyncHandler(HttpMethodHandler): - """Handler for GET sync requests.""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - from lightapi.lightapi import _maybe_cached - - cls = type(endpoint) - pk = request.path_params.get("id") - if pk is not None: - return endpoint.retrieve(request, pk) - return _maybe_cached(cls, request, lambda: endpoint.list(request)) - - def get_options_response(self, endpoint: Any, request: Request) -> Response: - return OptionsHandler().get_options_response(endpoint, request) - - -class PostHandler(HttpMethodHandler): - """Handler for POST requests (create).""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - from lightapi.lightapi import _read_body - - data = await _read_body(request) - return await endpoint._create_async(data) - - -class PostSyncHandler(HttpMethodHandler): - """Handler for POST sync requests.""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - import asyncio - - from lightapi.lightapi import _read_body - - data = asyncio.run(_read_body(request)) - return endpoint.create(data) - - def get_options_response(self, endpoint: Any, request: Request) -> Response: - return OptionsHandler().get_options_response(endpoint, request) - - -class PutHandler(HttpMethodHandler): - """Handler for PUT requests (full update).""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - from lightapi.lightapi import _read_body - - data = await _read_body(request) - pk = request.path_params.get("id") - return await endpoint._update_async(request, pk, data) - - -class PatchHandler(HttpMethodHandler): - """Handler for PATCH requests (partial update).""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - from lightapi.lightapi import _read_body - - data = await _read_body(request) - pk = request.path_params.get("id") - return await endpoint._patch_async(request, pk, data) - - -class DeleteHandler(HttpMethodHandler): - """Handler for DELETE requests.""" - - async def handle(self, endpoint: Any, request: Request) -> Response: - pk = request.path_params.get("id") - return await endpoint._delete_async(request, pk) - - -class HttpDispatcher: - """Dispatcher for HTTP methods.""" - - _ASYNC_HANDLERS = { - "GET": GetHandler, - "POST": PostHandler, - "PUT": PutHandler, - "PATCH": PatchHandler, - "DELETE": DeleteHandler, - } - - _SYNC_HANDLERS = { - "GET": GetSyncHandler, - "POST": PostSyncHandler, - } - - @classmethod - def dispatch(cls, method: str, is_async: bool) -> HttpMethodHandler: - if is_async: - handler_cls = cls._ASYNC_HANDLERS.get(method, OptionsHandler) - else: - handler_cls = cls._SYNC_HANDLERS.get(method, OptionsHandler) - return handler_cls() - - @classmethod - def get_options(cls, method: str, endpoint: Any, request: Request) -> Response: - return OptionsHandler().get_options_response(endpoint, request) diff --git a/lightapi/lightapi.py b/lightapi/lightapi.py index 98e7d98..e25f839 100644 --- a/lightapi/lightapi.py +++ b/lightapi/lightapi.py @@ -3,33 +3,49 @@ from __future__ import annotations import asyncio +import concurrent.futures +import contextlib import importlib -import json import logging import os import warnings -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import AsyncIterator, Callable +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Final import uvicorn -from sqlalchemy import create_engine +from sqlalchemy import Engine, MetaData, create_engine +from sqlalchemy.engine import make_url +from sqlalchemy.exc import SQLAlchemyError from starlette.applications import Starlette -from starlette.background import BackgroundTasks +from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware as StarletteCORSMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import Response from starlette.routing import Route -from lightapi.authentication import AllowAny, BasicAuthentication, JWTAuthentication -from lightapi.cache import get_cached, invalidate_cache_prefix, set_cached -from lightapi.constants import RESPONSE_KEY_DETAIL, HTTPStatus +from lightapi._dict_config_loader import DictConfig +from lightapi._login import LoginEndpoint +from lightapi.authentication import ( + BaseAuthentication, + BasicAuthentication, + JWTAuthentication, +) +from lightapi.cache import RedisCacheBackend +from lightapi.constants import CRUD_VERBS, ExecutionMode, HttpVerb +from lightapi.endpoint_handler import AppContext, EndpointHandler, MiddlewareChain from lightapi.exceptions import ConfigurationError from lightapi.health import HEALTH_PATH, HealthCheckEndpoint +from lightapi.rate_limiter import RateLimiter from lightapi.rest import RestEndpoint from lightapi.session_manager import SessionManager -from lightapi.yaml_loader import load_config +from lightapi.yaml_config import LightAPIConfig if TYPE_CHECKING: - from lightapi.rate_limiter import RateLimiter + from sqlalchemy.ext.asyncio import AsyncEngine + + from lightapi.config import Authentication + from lightapi.core import Middleware logger = logging.getLogger(__name__) @@ -48,68 +64,56 @@ class LightApi: app = LightApi.from_config("lightapi.yaml") """ + # Limits of the login routes when the app sets none. + _LOGIN_RATE_LIMITS: Final[dict[str, int]] = { + "requests_per_minute": 1000, + "requests_per_hour": 10000, + "requests_per_day": 100000, + } + # An `async def` of any of these on an endpoint switches the app to async mode. + _ASYNC_HANDLER_NAMES: Final = ( + "queryset", + *(verb.value.lower() for verb in CRUD_VERBS), + ) + def __init__( self, - engine: Any = None, + engine: Engine | AsyncEngine | None = None, database_url: str | None = None, mode: str | None = None, # Auto-detected if not provided cors_origins: list[str] | None = None, - middlewares: list[type] | None = None, + middlewares: list[type[Middleware]] | None = None, auth_path: str = "/auth", session_manager: SessionManager | None = None, - rate_limiter: "RateLimiter | dict[str, int] | None" = None, + rate_limiter: RateLimiter | dict[str, int] | None = None, login_validator: Callable[[str, str], dict[str, Any] | None] | None = None, use_test_isolation: bool = False, ) -> None: - if engine is None and database_url: - engine = create_engine(database_url) - elif engine is None: - url = os.environ.get("LIGHTAPI_DATABASE_URL") - if url is None: - raise ConfigurationError( - "No database configured. Provide engine=..., database_url=..., or set " - "LIGHTAPI_DATABASE_URL environment variable." - ) - engine = create_engine(url) - - # Store engine first (we'll detect mode later) - self._engine = engine - - # Use explicit mode if provided, otherwise will be auto-detected in register() - if mode is not None: - if mode not in ("sync", "async"): + self._mode = ExecutionMode.from_user(mode) + # Sync or async is a runtime fact (ExecutionMode), so the attribute is loose. + self._engine: Any = ( + engine + if engine is not None + else self._engine_from_url(database_url, self._mode) + ) + if self._engine_is_async(self._engine): + # The README promises the mode is detected when omitted: an AsyncEngine, + # or an async driver in the URL (sqlite+aiosqlite, postgresql+asyncpg). + if mode is not None and self._mode is ExecutionMode.SYNC: raise ConfigurationError( - f"mode must be 'sync' or 'async', got '{mode}'" + "mode='sync' with an async engine or driver: drop mode, or use " + "a sync engine." ) - self._mode = mode - else: - self._mode = "sync" # Will be auto-detected in register() - - # Create session manager + self._mode = ExecutionMode.ASYNC self._session_manager = session_manager or SessionManager( - engine, use_test_isolation=use_test_isolation + self._engine, use_test_isolation=use_test_isolation ) - - # Store login_validator for backward compatibility self._login_validator = login_validator - - # Rate limiter setup - self._rate_limiter_global: RateLimiter | None = None - if rate_limiter is not None: - from lightapi.rate_limiter import RateLimiter as RL - - if isinstance(rate_limiter, RL): - self._rate_limiter_global = rate_limiter - elif isinstance(rate_limiter, dict): - self._rate_limiter_global = RL( - requests_per_minute=rate_limiter.get("requests_per_minute", 1000), - requests_per_hour=rate_limiter.get("requests_per_hour", 10000), - requests_per_day=rate_limiter.get("requests_per_day", 100000), - ) + self._rate_limiter_global = self._as_rate_limiter(rate_limiter) self._routes: list[Route] = [] - self._endpoint_map: dict[str, type] = {} - self._middlewares: list[type] = middlewares or [] + self._endpoint_map: dict[str, type[RestEndpoint]] = {} + self._middlewares: list[type[Middleware]] = middlewares or [] self._cors_origins: list[str] = cors_origins or [] self._auth_path = auth_path @@ -117,445 +121,92 @@ def __init__( # Registration # ───────────────────────────────────────────────────────────────────────── - def register(self, mapping: dict[str, type]) -> None: + def register(self, mapping: dict[str, type[RestEndpoint]]) -> None: """Register endpoint classes against URL patterns. Args: mapping: ``{"/path": EndpointClass}`` dictionary. Each class must be a ``RestEndpoint`` subclass. """ - - # First pass: detect if any endpoint has async methods to auto-set mode for path, cls in mapping.items(): - if not (isinstance(cls, type) and issubclass(cls, RestEndpoint)): - raise ConfigurationError( - f"register() value for '{path}' must be a RestEndpoint subclass, " - f"got {cls!r}." - ) - - # Auto-detect mode from async methods - if self._mode == "sync": - for method_name in ( - "queryset", - "get", - "post", - "put", - "patch", - "delete", - ): - method = getattr(cls, method_name, None) - # Check if it's a coroutine function (skip non-callable like SQLAlchemy Select) - if ( - method is not None - and callable(method) - and asyncio.iscoroutinefunction(method) - ): - self._mode = "async" - break - - # Validate mode if explicitly set - if self._mode == "async": - try: - importlib.import_module("sqlalchemy.ext.asyncio") - from sqlalchemy.ext.asyncio import AsyncEngine - - if not isinstance(self._engine, AsyncEngine): - raise ConfigurationError( - f"mode='async' requires AsyncEngine, got {type(self._engine).__name__}" - ) - except ImportError: - raise ConfigurationError( - "mode='async' requires async dependencies. " - "Install: uv add 'lightapi[async]'" - ) - + self._require_rest_endpoint(path, cls) + self._require_sync_basic_validator(path, cls) + + # An `async def` verb or queryset on any endpoint switches the app to async. + if self._mode is ExecutionMode.SYNC and any( + map(self._has_async_handlers, mapping.values()) + ): + self._mode = ExecutionMode.ASYNC + if self._mode is ExecutionMode.ASYNC: + self._require_async_engine(self._engine) + + app_context = AppContext( + MiddlewareChain(self._middlewares), self._mode, self._login_validator + ) for path, cls in mapping.items(): - # Inject session manager into endpoint class - cls._session_manager = self._session_manager - - # Always map when test isolation is enabled or when not already mapped - if not getattr(cls, "_reflect_deferred", False): - from lightapi.session_manager import get_unique_table_name - from lightapi.table_mapping import map_imperatively - - # Use test isolation table name if available - meta_obj = getattr(cls, "Meta", None) - table_name = ( - getattr(meta_obj, "table", None) or f"{cls.__name__.lower()}s" - ) - - # Always re-map with test-specific metadata when test isolation is enabled - if self._session_manager._use_test_isolation: - # Generate unique table name for test isolation - table_name = get_unique_table_name(table_name) - logger.debug( - "Mapping %s with test isolation table name: %s", - cls.__name__, - table_name, - ) - - # Create a new meta object with the unique table name - class TestIsolationMeta: - table = table_name - reflect = getattr(meta_obj, "reflect", False) - - map_imperatively( - cls, - cls.__name__, - columns=getattr(cls, "_all_columns", []), - meta_obj=TestIsolationMeta, - session_manager=self._session_manager, - ) - else: - # Always map when not using test isolation - map_imperatively( - cls, - cls.__name__, - columns=getattr(cls, "_all_columns", []), - meta_obj=meta_obj, - session_manager=self._session_manager, - ) - - # Log registration for transparency - logger.info(f"Registering endpoint {path} -> {cls.__name__}") - logger.debug(f" SQLAlchemy metadata: {cls._meta}") - - # Warn if endpoint defines async queryset but engine is sync - if self._mode == "sync": - qs = cls.__dict__.get("queryset") - if qs is None: - qs = getattr(cls, "queryset", None) - if qs is not None and asyncio.iscoroutinefunction(qs): - warnings.warn( - f"'{cls.__name__}.queryset' is async but engine is sync; " - "sync path will be used.", - RuntimeWarning, - stacklevel=2, - ) - # Perform deferred reflection now that an engine is available - if getattr(cls, "_reflect_deferred", False): - from lightapi.table_mapping import map_reflected as _map_reflected - - meta_obj = getattr(cls, "Meta", None) or type("Meta", (), {}) - partial = cls._meta.get("reflect") == "partial" - extra_cols = getattr(cls, "_reflect_partial_columns", []) - _map_reflected( - cls, - cls.__name__, - meta_obj, - partial, - extra_cols, - session_manager=self._session_manager, - ) - if getattr(cls, "_schema_deferred", False): - from sqlalchemy import inspect as sa_inspect - - from lightapi.schema import SchemaFactory - - table = sa_inspect(cls).persist_selectable - cls.__schema_create__, cls.__schema_read__ = ( - SchemaFactory.build_from_reflected_table(cls, table) - ) - cls._schema_deferred = False - cls._reflect_deferred = False - - allowed = cls._allowed_methods - from lightapi.handler_factory import ( - make_collection_handler, - make_detail_handler, - ) + self._register_endpoint(path, cls, app_context) - collection_route = Route( + def _register_endpoint( + self, path: str, cls: type[RestEndpoint], app_context: AppContext + ) -> None: + cls._session_manager = self._session_manager + cls._table_source.map(cls, self._session_manager) + logger.info("Registering endpoint %s -> %s", path, cls.__name__) + logger.debug(" SQLAlchemy metadata: %s", cls._meta) + + collection = EndpointHandler.for_collection(cls, app_context) + detail = EndpointHandler.for_detail(cls, app_context) + # Only the allowed verbs, so Starlette keeps routing by method (two classes + # may share a path) and a disallowed verb never reaches the guard or the + # middlewares: its JSON 405 comes from the app's handler in build_app(). + self._routes.append( + Route( path, - endpoint=make_collection_handler( - cls, self._middlewares, self._mode == "async" - ), - methods=[m for m in allowed if m in {"GET", "POST"}], + endpoint=collection.handle, + methods=collection.methods, + name=f"{cls.__name__}_collection", ) - detail_route = Route( + ) + self._routes.append( + Route( path.rstrip("/") + "/{id:int}", - endpoint=make_detail_handler( - cls, self._middlewares, self._mode == "async" - ), - methods=[m for m in allowed if m in {"GET", "PUT", "PATCH", "DELETE"}], + endpoint=detail.handle, + methods=detail.methods, + name=f"{cls.__name__}_detail", ) - self._routes.append(collection_route) - self._routes.append(detail_route) - self._endpoint_map[path] = cls - - # Auto-register /auth/login and /auth/token when JWT or Basic auth is used - - auth_backends: set[type] = set() - jwt_config_expiration: int | None = None - jwt_config_extra_claims: list[str] | None = None - jwt_config_algorithm: str | None = None - for cls in self._endpoint_map.values(): - auth_cfg = getattr(cls, "_meta", {}).get("authentication") - if auth_cfg and auth_cfg.backend: - auth_backends.add(auth_cfg.backend) - if ( - auth_cfg.backend is JWTAuthentication - and jwt_config_expiration is None - ): - jwt_config_expiration = getattr(auth_cfg, "jwt_expiration", None) - jwt_config_extra_claims = getattr( - auth_cfg, "jwt_extra_claims", None - ) - jwt_config_algorithm = getattr(auth_cfg, "jwt_algorithm", None) - - # Check if any endpoint has authentication configured - auth_backends_list = [] - for cls in self._endpoint_map.values(): - auth_cfg = getattr(cls, "_meta", {}).get("authentication") - if auth_cfg and auth_cfg.backend: - # Store login_validator in auth_cfg for use by _check_auth - # Use object.__setattr__ to bypass frozen dataclass restriction - object.__setattr__(auth_cfg, "_login_validator", self._login_validator) - - # Store backend instance with its config - backend = auth_cfg.backend - # Initialize backend with appropriate parameters based on type - if backend.__name__ == "JWTAuthentication": - backend_instance = backend( - expiration=getattr(auth_cfg, "jwt_expiration", None), - algorithm=getattr(auth_cfg, "jwt_algorithm", None), - rate_limiter=getattr(auth_cfg, "rate_limiter", None), - ) - elif backend.__name__ == "BasicAuthentication": - backend_instance = backend( - rate_limiter=getattr(auth_cfg, "rate_limiter", None), - login_validator=self._login_validator, - ) - else: - backend_instance = backend() - auth_backends_list.append((cls, auth_cfg, backend_instance)) - - # Auto-register /auth/login and /auth/token when JWT or Basic auth is used - has_auth_backends = any( - isinstance(auth[2], (JWTAuthentication, BasicAuthentication)) - for auth in auth_backends_list ) + self._endpoint_map[path] = cls - if has_auth_backends: - # Use global rate limiter if set, otherwise create default - rate_limiter = self._rate_limiter_global - if rate_limiter is None: - from lightapi.rate_limiter import RateLimiter - - rate_limiter = RateLimiter( - requests_per_minute=1000, - requests_per_hour=10000, - requests_per_day=100000, - ) - - has_jwt = any( - isinstance(auth[2], JWTAuthentication) for auth in auth_backends_list - ) - auth_path = self._auth_path.rstrip("/") - - login_path = f"{auth_path}/login" - token_path = f"{auth_path}/token" + def _login_routes(self) -> list[Route]: + """/auth/login and /auth/token, when an endpoint uses JWT or Basic auth.""" + backend = self._login_backend() + if backend is None: + return [] - self._routes = [ - route - for route in self._routes - if not ( - isinstance(route, Route) and route.path in {login_path, token_path} - ) - ] - - login_endpoint = self._make_login_endpoint( - has_jwt=has_jwt, - jwt_expiration=jwt_config_expiration, - jwt_extra_claims=jwt_config_extra_claims, - jwt_algorithm=jwt_config_algorithm, - rate_limiter=rate_limiter, - ) - self._routes.insert( - 0, - Route( - login_path, - login_endpoint, - methods=["POST"], - ), - ) - self._routes.insert( - 1, - Route( - token_path, - login_endpoint, - methods=["POST"], - ), - ) - - def _make_login_endpoint( - self, - *, - has_jwt: bool, - jwt_expiration: int | None, - jwt_extra_claims: list[str] | None, - jwt_algorithm: str | None, - rate_limiter: "RateLimiter", - ) -> Any: - """Create the login/token handler with captured config.""" - from lightapi._login import login_handler - from lightapi.authentication import JWTAuthentication - - # Create auth backend that wraps login_validator for validate_credentials - auth_backend = JWTAuthentication() - - # Override validate_credentials to use login_validator if provided - original_validate = auth_backend.validate_credentials - - def wrapped_validate(username: str, password: str) -> dict[str, Any] | None: - # Try login_validator first if provided - if self._login_validator is not None: - return self._login_validator(username, password) - # Otherwise try the original method - return original_validate(username, password) - - auth_backend.validate_credentials = wrapped_validate - - async def handler(request: Request) -> Response: - return await login_handler( - request, - has_jwt=has_jwt, - jwt_expiration=jwt_expiration, - jwt_extra_claims=jwt_extra_claims, - jwt_algorithm=jwt_algorithm, - rate_limiter=rate_limiter, - auth_backend=auth_backend, - ) - - return handler - - def _make_collection_handler(self, cls: type) -> Any: - app_middlewares = self._middlewares - is_async = self._mode == "async" - - async def handler(request: Request) -> Response: - endpoint = cls() - endpoint._background = BackgroundTasks() - endpoint._current_request = request - - pre_result = await _run_pre_middlewares(app_middlewares, request) - if pre_result is not None: - return pre_result - - auth_result = _check_auth(cls, request) - if auth_result is not None: - return auth_result - - if request.method == "GET": - get_override = getattr(cls, "get", None) - if get_override and asyncio.iscoroutinefunction(get_override): - result = await get_override(endpoint, request) - elif is_async: - result = await endpoint._list_async(request) - else: - result = _maybe_cached(cls, request, lambda: endpoint.list(request)) - elif request.method == "POST": - data = await _read_body(request) - post_override = getattr(cls, "post", None) - if post_override and asyncio.iscoroutinefunction(post_override): - result = await post_override(endpoint, request) - elif is_async: - result = await endpoint._create_async(data) - else: - result = endpoint.create(data) - else: - allowed = ", ".join(sorted(cls._allowed_methods & {"GET", "POST"})) - result = JSONResponse( - {RESPONSE_KEY_DETAIL: f"Method Not Allowed. Allowed: {allowed}"}, - status_code=HTTPStatus.METHOD_NOT_ALLOWED, - headers={"Allow": allowed}, - ) - - # Wrap dict responses in JSONResponse - response = _wrap_dict_response(result) - - if not is_async: - _maybe_invalidate_cache(cls, request) - - if endpoint._background.tasks: - response.background = endpoint._background - - return await _run_post_middlewares(app_middlewares, request, response) - - handler.__name__ = f"{cls.__name__}_collection" - handler.__endpoint_cls__ = cls - return handler - - def _make_detail_handler(self, cls: type) -> Any: - app_middlewares = self._middlewares - is_async = self._mode == "async" - - async def handler(request: Request) -> Response: - pk: int = request.path_params["id"] - endpoint = cls() - endpoint._background = BackgroundTasks() - endpoint._current_request = request - - pre_result = await _run_pre_middlewares(app_middlewares, request) - if pre_result is not None: - return pre_result - - auth_result = _check_auth(cls, request) - if auth_result is not None: - return auth_result - - if request.method == "GET": - get_override = getattr(cls, "get", None) - if get_override and asyncio.iscoroutinefunction(get_override): - result = await get_override(endpoint, request) - elif is_async: - result = await endpoint._retrieve_async(request, pk) - else: - result = _maybe_cached( - cls, request, lambda: endpoint.retrieve(request, pk) - ) - elif request.method in {"PUT", "PATCH"}: - data = await _read_body(request) - partial = request.method == "PATCH" - put_override = getattr(cls, "put" if not partial else "patch", None) - if put_override and asyncio.iscoroutinefunction(put_override): - result = await put_override(endpoint, request) - elif is_async: - result = await endpoint._update_async(data, pk, partial=partial) - else: - result = endpoint.update(data, pk, partial=partial) - elif request.method == "DELETE": - delete_override = getattr(cls, "delete", None) - if delete_override and asyncio.iscoroutinefunction(delete_override): - result = await delete_override(endpoint, request) - elif is_async: - result = await endpoint._destroy_async(request, pk) - else: - result = endpoint.destroy(request, pk) - else: - allowed = ", ".join( - sorted(cls._allowed_methods & {"GET", "PUT", "PATCH", "DELETE"}) - ) - result = JSONResponse( - {RESPONSE_KEY_DETAIL: f"Method Not Allowed. Allowed: {allowed}"}, - status_code=HTTPStatus.METHOD_NOT_ALLOWED, - headers={"Allow": allowed}, - ) - - # Wrap dict responses in JSONResponse - response = _wrap_dict_response(result) - - if not is_async: - _maybe_invalidate_cache(cls, request) - - if endpoint._background.tasks: - response.background = endpoint._background - - return await _run_post_middlewares(app_middlewares, request, response) + rate_limiter = self._rate_limiter_global or RateLimiter( + **self._LOGIN_RATE_LIMITS + ) + login = LoginEndpoint(backend, self._login_validator, rate_limiter) + auth_path = self._auth_path.rstrip("/") + return [ + Route(f"{auth_path}/{name}", login.handle, methods=[HttpVerb.POST.value]) + for name in ("login", "token") + ] - handler.__name__ = f"{cls.__name__}_detail" - handler.__endpoint_cls__ = cls - return handler + def _login_backend(self) -> BaseAuthentication | None: + """The backend that answers the login routes: a JWT one if any, else Basic.""" + configured: list[Authentication] = [ + authentication + for cls in self._endpoint_map.values() + if (authentication := cls._meta.get("authentication")) + and authentication.backend + ] + for kind in (JWTAuthentication, BasicAuthentication): + for authentication in configured: + backend = authentication.backend + if backend is not None and issubclass(backend, kind): + return authentication.build_backend(self._login_validator) + return None # ───────────────────────────────────────────────────────────────────────── # Run @@ -563,48 +214,40 @@ async def handler(request: Request) -> Response: def run( self, - host: str = "0.0.0.0", + host: str = "0.0.0.0", # noqa: S104 the documented default; pass host= to narrow it port: int = 8000, debug: bool = False, reload: bool = False, ) -> None: """Create tables, build the Starlette ASGI app and start uvicorn.""" - if self._mode == "async": - _validate_async_dependencies(self._engine) - self._create_tables() - self._check_cache_connections() - - on_startup = [self._create_tables] if self._mode == "async" else [] - app = Starlette(debug=debug, routes=self._asgi_routes(), on_startup=on_startup) - - if self._cors_origins: - app.add_middleware( - StarletteCORSMiddleware, - allow_origins=self._cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - + if self._mode is ExecutionMode.ASYNC: + self._validate_async_dependencies(self._engine) uvicorn.run( - app, + self.build_app(debug=debug), host=host, port=port, log_level="debug" if debug else "info", reload=reload, ) - def build_app(self) -> Starlette: + def build_app(self, *, debug: bool = False) -> Starlette: """Build and return the Starlette ASGI app without starting the server. - Useful for testing with ``httpx.AsyncClient`` or ``starlette.testclient.TestClient``. - For async engines, table creation is deferred to the Starlette on_startup handler + Useful for testing with ``httpx.AsyncClient`` or + ``starlette.testclient.TestClient``. + For async engines, table creation is deferred to the app's lifespan startup so it runs inside the correct event loop (not a throwaway thread loop). """ self._create_tables() self._check_cache_connections() - on_startup = [self._create_tables] if self._mode == "async" else [] - app = Starlette(routes=self._asgi_routes(), on_startup=on_startup) + lifespan = self._lifespan if self._mode is ExecutionMode.ASYNC else None + app = Starlette( + debug=debug, + routes=self._asgi_routes(), + lifespan=lifespan, + # Starlette's own 405 is text/plain; the README documents a JSON one. + exception_handlers={HTTPStatus.METHOD_NOT_ALLOWED: self._json_405}, + ) if self._cors_origins: app.add_middleware( StarletteCORSMiddleware, @@ -616,8 +259,9 @@ def build_app(self) -> Starlette: return app def _asgi_routes(self) -> list[Route]: - """Registered endpoint routes plus the always-on ``/healthz`` probe.""" + """Login routes first, then the endpoints, then the always-on ``/healthz``.""" return [ + *self._login_routes(), *self._routes, Route(HEALTH_PATH, HealthCheckEndpoint), ] @@ -627,7 +271,11 @@ def _asgi_routes(self) -> list[Route]: # ───────────────────────────────────────────────────────────────────────── @classmethod - def from_config(cls, config_path: str, **kwargs: Any) -> "LightApi": + def from_config( + cls, + config_path: str, + **kwargs: Any, # noqa: ANN401 constructor kwargs, forwarded + ) -> LightApi: """Create a LightApi instance from a ``lightapi.yaml`` file. Uses the declarative format: ``database.url``, ``endpoints[].route``, @@ -636,14 +284,18 @@ def from_config(cls, config_path: str, **kwargs: Any) -> "LightApi": Kwargs override YAML values (e.g. engine=..., database_url=...). """ - - return load_config(cls, config_path, **kwargs) + return LightAPIConfig.from_file(config_path).build_app(cls, **kwargs) @classmethod - def from_dict(cls, config: dict[str, Any], **kwargs: Any) -> "LightApi": + def from_dict( + cls, + config: dict[str, Any], + **kwargs: Any, # noqa: ANN401 constructor kwargs, forwarded + ) -> LightApi: """Create a LightApi instance from a Python dictionary. - Simpler alternative to YAML config for programmatic setup. + Deprecated: removed in 0.2.0. Declare RestEndpoint subclasses and call + register(), or use from_config() with a YAML file. Example:: @@ -662,9 +314,14 @@ def from_dict(cls, config: dict[str, Any], **kwargs: Any) -> "LightApi": } app = LightApi.from_dict(config) """ - from lightapi._dict_config_loader import load_from_dict - - return load_from_dict(cls, config, **kwargs) + warnings.warn( + "LightApi.from_dict is deprecated and will be removed in 0.2.0. " + "Declare RestEndpoint subclasses and call register(), or use " + "from_config() with a YAML file.", + DeprecationWarning, + stacklevel=2, + ) + return DictConfig(config).build(cls, **kwargs) # ───────────────────────────────────────────────────────────────────────── # Internal helpers @@ -676,235 +333,198 @@ def _create_tables(self) -> None: logger.debug("_create_tables called with metadata: %s", metadata) logger.debug("Available tables in metadata: %s", list(metadata.tables.keys())) try: - if self._mode == "async": - # For async engines, table creation must run inside the same event loop - # that will serve requests (uvicorn's loop), so we defer it to on_startup - # unless we are already inside a running loop (pytest-asyncio). - try: - asyncio.get_running_loop() - - # Inside a running loop — create tables directly here (test context). - async def _create_inside_loop() -> None: - async with self._engine.begin() as conn: - await conn.run_sync(metadata.create_all) - - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - pool.submit(asyncio.run, _create_inside_loop()).result() - except RuntimeError: - # No running loop; registration is deferred to the on_startup handler - # that build_app() adds. Nothing to do here. - pass + if self._mode is ExecutionMode.ASYNC: + self._create_tables_inside_running_loop(metadata) + elif self._session_manager.is_async: + # create_all() needs a sync engine, and register() only switches to + # async mode for an endpoint that needs it. Before, this was an + # AttributeError swallowed into the same warning. + logger.warning( + "Table creation skipped: an AsyncEngine without mode='async'. " + "Pass mode='async' or create the tables yourself." + ) else: - engine = self._engine - metadata.create_all(bind=engine) - logger.info("Tables created/verified against %s", engine.url) - except Exception as exc: - logger.warning("Table creation warning: %s", exc) + metadata.create_all(bind=self._engine) + logger.info("Tables created/verified against %s", self._engine.url) + except SQLAlchemyError: + logger.warning("Table creation warning", exc_info=True) + + @contextlib.asynccontextmanager + async def _lifespan(self, _: Starlette) -> AsyncIterator[None]: + """Startup of an async app: create the tables on the loop that serves it. + + The DDL must run on this loop: a connection opened on another loop (a + worker thread running asyncio.run) goes back to the pool and fails the next + request with "attached to a different loop" (asyncpg). + """ + metadata = self._session_manager.metadata + try: + async with self._engine.begin() as conn: + await conn.run_sync(metadata.create_all) + logger.info("Tables created/verified against %s", self._engine.url) + except SQLAlchemyError: + logger.warning("Table creation warning", exc_info=True) + yield + + def _create_tables_inside_running_loop(self, metadata: MetaData) -> None: + """build_app() called inside a loop that is already running (pytest-asyncio) + creates the tables on a loop of its own, in a worker thread; a served app + gets them from the lifespan instead.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return - def _check_cache_connections(self) -> None: - """Emit RuntimeWarning if any endpoint has cache configured but Redis is unreachable.""" - import warnings + async def create_all() -> None: + async with self._engine.begin() as conn: + await conn.run_sync(metadata.create_all) - for cls in self._endpoint_map.values(): - cache_cfg = getattr(cls, "_meta", {}).get("cache") - if cache_cfg: - from lightapi.cache import _ping_redis + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(asyncio.run, create_all()).result() - if not _ping_redis(): + def _check_cache_connections(self) -> None: + """RuntimeWarning when an endpoint has a cache but Redis is unreachable.""" + for cls in self._endpoint_map.values(): + if cls._meta.get("cache"): + if not RedisCacheBackend.shared().ping(): warnings.warn( - "Redis is configured for caching but is not reachable at startup. " - "Cache will be skipped for all requests.", + "Redis is configured for caching but is not reachable at " + "startup. Cache will be skipped for all requests.", RuntimeWarning, stacklevel=3, ) break + # ───────────────────────────────────────────────────────────────────────── + # Constructor and register() checks + # ───────────────────────────────────────────────────────────────────────── -# ───────────────────────────────────────────────────────────────────────────── -# Handler utilities -# ───────────────────────────────────────────────────────────────────────────── + @staticmethod + def _engine_from_url( + database_url: str | None, mode: ExecutionMode + ) -> Engine | AsyncEngine: + url = database_url or os.environ.get("LIGHTAPI_DATABASE_URL") + if url is None: + raise ConfigurationError( + "No database configured. Provide engine=..., database_url=..., or set " + "LIGHTAPI_DATABASE_URL environment variable." + ) + if make_url(url).get_dialect().is_async: + # sqlalchemy[asyncio] is the optional `async` extra. + from sqlalchemy.ext.asyncio import create_async_engine + return create_async_engine(url) + if mode is ExecutionMode.ASYNC: + shown = make_url(url).render_as_string(hide_password=True) + raise ConfigurationError( + "mode='async' needs an async driver in the database URL " + f"(sqlite+aiosqlite://, postgresql+asyncpg://), got '{shown}'." + ) + return create_engine(url) -def _validate_async_dependencies(engine: Any) -> None: - """Raise ConfigurationError if async SQLAlchemy extras or dialect driver are missing.""" - try: - importlib.import_module("sqlalchemy.ext.asyncio") - except ImportError: - raise ConfigurationError( - "AsyncEngine supplied but 'sqlalchemy[asyncio]' is not installed. " - "Install with: uv add 'sqlalchemy[asyncio]'" - ) - dialect = engine.url.get_dialect().name - driver_map = {"postgresql": "asyncpg", "sqlite": "aiosqlite", "mysql": "aiomysql"} - driver = driver_map.get(dialect) - if driver: + @staticmethod + def _engine_is_async(engine: object) -> bool: try: - importlib.import_module(driver) + from sqlalchemy.ext.asyncio import AsyncEngine except ImportError: + return False + return isinstance(engine, AsyncEngine) + + @staticmethod + async def _json_405(_request: Request, exc: Exception) -> Response: + """Starlette raised its 405 for a verb the route does not list.""" + headers = exc.headers if isinstance(exc, HTTPException) else None + allowed = (headers or {}).get("Allow", "") + return EndpointHandler.method_not_allowed( + allowed.split(", ") if allowed else () + ) + + @classmethod + def _as_rate_limiter( + cls, value: RateLimiter | dict[str, int] | None + ) -> RateLimiter | None: + if isinstance(value, dict): + return RateLimiter.from_limits(value, cls._LOGIN_RATE_LIMITS) + return value if isinstance(value, RateLimiter) else None + + @staticmethod + def _require_rest_endpoint(path: str, cls: object) -> None: + if not (isinstance(cls, type) and issubclass(cls, RestEndpoint)): raise ConfigurationError( - f"Async driver for '{dialect}' is not installed. " - f"Install with: uv add {driver}" + f"register() value for '{path}' must be a RestEndpoint subclass, " + f"got {cls!r}." ) + @staticmethod + def _require_sync_basic_validator(path: str, cls: type[RestEndpoint]) -> None: + """The guard calls a Basic backend's validate_credentials without awaiting. -async def _read_body(request: Request) -> dict[str, Any]: - """Read and parse JSON body; return {} on failure.""" - - try: - body = await request.body() - return json.loads(body) if body else {} - except Exception: - return {} - - -def _check_auth( - cls: type, - request: Request, -) -> Response | None: - """Run authentication + permission checks; return 401/403 response or None.""" - - auth_cfg = cls._meta.get("authentication") - if auth_cfg is None: - return None - - backend = auth_cfg.backend - permission_cls = auth_cfg.permission - - # Resolve permission for this method - if permission_cls is not None: - if isinstance(permission_cls, dict): - perm_cls = permission_cls.get(request.method) - if perm_cls is None: - perm_cls = AllowAny - else: - perm_cls = permission_cls - else: - perm_cls = AllowAny - - # AllowAny means the endpoint is fully public — skip backend authentication. - if perm_cls is AllowAny: - return None - - if backend is not None: - # Create authenticator with config based on backend type - # Note: We need to access self._login_validator from the LightApi instance - # but _check_auth is a standalone function. For now, we'll rely on the - # authentication backend's validate_credentials method being overridden - # or use the login_validator from the global config - login_validator = getattr(auth_cfg, "_login_validator", None) - - if backend.__name__ == "JWTAuthentication": - authenticator = backend( - expiration=getattr(auth_cfg, "jwt_expiration", None), - algorithm=getattr(auth_cfg, "jwt_algorithm", None), - rate_limiter=getattr(auth_cfg, "rate_limiter", None), - ) - elif backend.__name__ == "BasicAuthentication": - authenticator = backend( - rate_limiter=getattr(auth_cfg, "rate_limiter", None), - login_validator=login_validator, + An ``async def`` there returns a coroutine, which is truthy, so every Basic + credential would pass (B6-005). The login route awaits it, the guard cannot. + """ + authentication = cls._meta.get("authentication") + backend = getattr(authentication, "backend", None) + if ( + isinstance(backend, type) + and issubclass(backend, BasicAuthentication) + and asyncio.iscoroutinefunction(backend.validate_credentials) + ): + raise ConfigurationError( + f"'{path}': {backend.__name__}.validate_credentials is async, but a " + "BasicAuthentication backend checks every request synchronously; " + "make it a plain def (JWTAuthentication may keep an async one)." ) - else: - authenticator = backend() - if not authenticator.authenticate(request): - return JSONResponse( - {RESPONSE_KEY_DETAIL: "Authentication credentials invalid."}, - status_code=HTTPStatus.UNAUTHORIZED, + @classmethod + def _has_async_handlers(cls, endpoint_cls: type[RestEndpoint]) -> bool: + # A queryset may be a Select, which is not callable, so check before asking. + return any( + callable(handler) and asyncio.iscoroutinefunction(handler) + for handler in ( + getattr(endpoint_cls, name, None) for name in cls._ASYNC_HANDLER_NAMES ) + ) - if perm_cls is not None: - perm = perm_cls() - if not perm.has_permission(request): - return JSONResponse( - { - RESPONSE_KEY_DETAIL: "You do not have permission to perform this action." - }, - status_code=HTTPStatus.FORBIDDEN, + @staticmethod + def _require_async_engine(engine: object) -> None: + try: + # sqlalchemy[asyncio] is the optional `async` extra. + from sqlalchemy.ext.asyncio import AsyncEngine + except ImportError: + raise ConfigurationError( + "mode='async' requires async dependencies. " + "Install: uv add 'lightapi[async]'" + ) from None + if not isinstance(engine, AsyncEngine): + raise ConfigurationError( + f"mode='async' requires AsyncEngine, got {type(engine).__name__}" ) - return None - - -async def _run_pre_middlewares( - middlewares: list[type], request: Request -) -> Response | None: - """Run pre-request middleware; supports both sync and async process() methods.""" - for mw_cls in middlewares: - mw = mw_cls() - if asyncio.iscoroutinefunction(mw.process): - result = await mw.process(request, None) - else: - result = mw.process(request, None) - if result is not None: - return result - return None - - -async def _run_post_middlewares( - middlewares: list[type], request: Request, response: Response -) -> Response: - """Run post-response middleware in reverse order; supports sync and async process().""" - for mw_cls in reversed(middlewares): - mw = mw_cls() - if asyncio.iscoroutinefunction(mw.process): - result = await mw.process(request, response) - else: - result = mw.process(request, response) - if result is not None: - response = result - return response - - -def _maybe_cached(cls: type, request: Request, fn: Any) -> Response: - """Serve from Redis cache (GET only) or call fn() and populate cache.""" - - cache_cfg = cls._meta.get("cache") - if cache_cfg is None: - return fn() - - key = _cache_key(cls, request) - try: - cached = get_cached(key) - except Exception: - cached = None - if cached is not None: - return JSONResponse(cached) - response = fn() - if isinstance(response, JSONResponse) and response.status_code == 200: - import json - + @staticmethod + def _validate_async_dependencies(engine: Engine | AsyncEngine) -> None: + """ConfigurationError if the async SQLAlchemy extra or the driver is missing.""" try: - set_cached(key, json.loads(response.body), cache_cfg.ttl) - except Exception: - pass - return response - - -def _maybe_invalidate_cache(cls: type, request: Request) -> None: - """Invalidate cache entries after mutating requests.""" - if request.method == "GET": - return - cache_cfg = cls._meta.get("cache") - if cache_cfg is None: - return - - invalidate_cache_prefix(_cache_key_prefix(cls)) - - -def _cache_key(cls: type, request: Request) -> str: - query = str(request.query_params) - return f"lightapi:{cls.__name__}:{request.url.path}:{query}" - - -def _cache_key_prefix(cls: type) -> str: - return f"lightapi:{cls.__name__}:" + importlib.import_module("sqlalchemy.ext.asyncio") + except ImportError: + raise ConfigurationError( + "AsyncEngine supplied but 'sqlalchemy[asyncio]' is not installed. " + "Install with: uv add 'sqlalchemy[asyncio]'" + ) from None + dialect = engine.url.get_dialect().name + driver = _ASYNC_DRIVERS.get(dialect) + if driver: + try: + importlib.import_module(driver) + except ImportError: + raise ConfigurationError( + f"Async driver for '{dialect}' is not installed. " + f"Install with: uv add {driver}" + ) from None -def _wrap_dict_response(result: Any) -> Response: - """Wrap dict responses in JSONResponse, leave Response unchanged.""" - if isinstance(result, dict): - return JSONResponse(result) - return result +# The async driver each dialect needs; a dialect missing here is not checked. +_ASYNC_DRIVERS: Final[dict[str, str]] = { + "postgresql": "asyncpg", + "sqlite": "aiosqlite", + "mysql": "aiomysql", +} diff --git a/lightapi/middleware_pipeline.py b/lightapi/middleware_pipeline.py deleted file mode 100644 index 5a35841..0000000 --- a/lightapi/middleware_pipeline.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Middleware pipeline for request/response processing.""" - -from __future__ import annotations - -import asyncio - -from starlette.requests import Request -from starlette.responses import Response - - -class MiddlewarePipeline: - """Pipeline for processing middleware in order. - - Supports both sync and async middleware processors. - """ - - def __init__(self, middlewares: list[type] | None = None) -> None: - """Initialize the middleware pipeline. - - Args: - middlewares: List of middleware classes to apply - """ - self._middlewares: list[type] = middlewares or [] - - def add(self, middleware: type) -> "MiddlewarePipeline": - """Add a middleware to the pipeline. - - Args: - middleware: Middleware class to add - - Returns: - Self for chaining - """ - self._middlewares.append(middleware) - return self - - async def process_request(self, request: Request) -> Response | None: - """Process request through all pre-request middleware. - - Args: - request: The incoming request - - Returns: - Response if a middleware returned early, None to continue - """ - for mw_cls in self._middlewares: - mw = mw_cls() - if asyncio.iscoroutinefunction(mw.process): - result = await mw.process(request, None) - else: - result = mw.process(request, None) - if result is not None: - return result - return None - - async def process_response(self, request: Request, response: Response) -> Response: - """Process response through all post-request middleware. - - Runs middleware in reverse order (LIFO). - - Args: - request: The original request - response: The response to process - - Returns: - The processed response - """ - for mw_cls in reversed(self._middlewares): - mw = mw_cls() - if asyncio.iscoroutinefunction(mw.process): - result = await mw.process(request, response) - else: - result = mw.process(request, response) - if result is not None: - response = result - return response - - def __len__(self) -> int: - """Return the number of middlewares in the pipeline.""" - return len(self._middlewares) - - def __bool__(self) -> bool: - """Return True if the pipeline has middlewares.""" - return bool(self._middlewares) diff --git a/lightapi/middleware_runner.py b/lightapi/middleware_runner.py deleted file mode 100644 index e687e06..0000000 --- a/lightapi/middleware_runner.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Middleware runner for pre/post middleware execution.""" - -import asyncio - -from starlette.requests import Request -from starlette.responses import Response - - -async def run_pre_middlewares( - middlewares: list[type], request: Request -) -> Response | None: - """Run pre-request middleware; supports both sync and async process() methods.""" - for mw_cls in middlewares: - mw = mw_cls() - if asyncio.iscoroutinefunction(mw.process): - result = await mw.process(request, None) - else: - result = mw.process(request, None) - if result is not None: - return result - return None - - -async def run_post_middlewares( - middlewares: list[type], request: Request, response: Response -) -> Response: - """Run post-response middleware in reverse order; sync/async process().""" - for mw_cls in reversed(middlewares): - mw = mw_cls() - if asyncio.iscoroutinefunction(mw.process): - result = await mw.process(request, response) - else: - result = mw.process(request, response) - if result is not None: - response = result - return response diff --git a/lightapi/pagination.py b/lightapi/pagination.py index caed7d9..32b360c 100644 --- a/lightapi/pagination.py +++ b/lightapi/pagination.py @@ -1,102 +1,121 @@ from __future__ import annotations import base64 +import binascii import json import math -from typing import TYPE_CHECKING, Any, Protocol +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any, ClassVar, Protocol -from sqlalchemy import func, select +from sqlalchemy import Select, func, select from sqlalchemy.orm import Session from starlette.requests import Request -from lightapi.constants import ( - CURSOR_PARAM, - PAGE_PARAM, - RESPONSE_KEY_COUNT, - RESPONSE_KEY_NEXT, - RESPONSE_KEY_PAGES, - RESPONSE_KEY_PREVIOUS, - RESPONSE_KEY_RESULTS, - VALID_PAGINATION_STYLES, -) +from lightapi.constants import CURSOR_PARAM, DEFAULT_PAGE_SIZE, PAGE_PARAM, ResponseKey if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession -class PaginatorProtocol(Protocol): - """Protocol for pagination strategies.""" +@dataclass(frozen=True, slots=True) +class Cursor: + """Position of a keyset page: the id of the last row the client saw.""" - def paginate( - self, - request: Request, - qs: Any, - session: Session, - page_size: int, - ) -> tuple[list[Any], int] | tuple[list[Any], str | None]: - """Paginate a queryset.""" - ... + last_id: int - def wrap( - self, - request: Request, - results: list[Any], - total: int = ..., - page: int = ..., - page_size: int = ..., - next_cursor: str | None = ..., - prev_cursor: str | None = ..., - ) -> dict[str, Any]: - """Wrap results in pagination response.""" - ... + # What a client-supplied cursor can fail with; the paginator ignores such a cursor. + INVALID: ClassVar[tuple[type[Exception], ...]] = ( + binascii.Error, + UnicodeDecodeError, + json.JSONDecodeError, + KeyError, + TypeError, + ValueError, + ) + def __post_init__(self) -> None: + if not isinstance(self.last_id, int): + raise TypeError(f"cursor id must be an int, got {self.last_id!r}") -class PaginatorFactory: - """Factory to create paginator instances based on configuration.""" + def encode(self) -> str: + return base64.urlsafe_b64encode( + json.dumps({"id": self.last_id}).encode() + ).decode() - @staticmethod - def create(style: str = "page_number") -> PaginatorProtocol: - """Create a paginator instance based on the style. + @classmethod + def decode(cls, text: str) -> Cursor: + return cls(json.loads(base64.urlsafe_b64decode(text.encode()))["id"]) - Args: - style: Pagination style ("page_number" or "cursor") - Returns: - A paginator instance implementing PaginatorProtocol +RowSerializer = Callable[[Any], dict[str, Any]] - Raises: - ValueError: If style is not recognized - """ - if style not in VALID_PAGINATION_STYLES: - raise ValueError( - f"Unknown pagination style: {style}. " - f"Valid styles: {VALID_PAGINATION_STYLES}" - ) - if style == "cursor": - return CursorPaginator() - return PageNumberPaginator() +class Paginator(Protocol): + """Turns a query into the body of a list response.""" + def render( + self, + request: Request, + qs: Select[Any], + session: Session, + serialize: RowSerializer, + ) -> dict[str, Any]: ... -def encode_cursor(last_id: int) -> str: - return base64.urlsafe_b64encode(json.dumps({"id": last_id}).encode()).decode() +class PaginationStyle(str, Enum): + """How a list response is split; the value is what `Pagination(style=...)` takes.""" -def decode_cursor(cursor: str) -> int: - return json.loads(base64.urlsafe_b64decode(cursor.encode()))["id"] + PAGE_NUMBER = "page_number" + CURSOR = "cursor" + + def build(self, page_size: int) -> Paginator: + if self is PaginationStyle.CURSOR: + return CursorPaginator(page_size) + return PageNumberPaginator(page_size) + + +class NoPagination: + """Every row in a single response.""" + + def render( + self, + request: Request, # noqa: ARG002 Paginator protocol + qs: Select[Any], + session: Session, + serialize: RowSerializer, + ) -> dict[str, Any]: + rows = session.execute(qs).scalars().all() + return {ResponseKey.RESULTS: [serialize(row) for row in rows]} class PageNumberPaginator: """Page-number based paginator that returns count/next/previous/results.""" + def __init__(self, page_size: int = DEFAULT_PAGE_SIZE) -> None: + self.page_size = page_size + + def render( + self, + request: Request, + qs: Select[Any], + session: Session, + serialize: RowSerializer, + ) -> dict[str, Any]: + rows, total = self.paginate(request, qs, session, self.page_size) + page = self._current_page(request) + results = [serialize(row) for row in rows] + return self.wrap(request, results, total, page, self.page_size) + def paginate( self, request: Request, - qs: Any, + qs: Select[Any], session: Session, page_size: int, ) -> tuple[list[Any], int]: - page = max(1, int(request.query_params.get(PAGE_PARAM, 1))) + page = self._current_page(request) offset = (page - 1) * page_size count_stmt = select(func.count()).select_from(qs.subquery()) total: int = session.execute(count_stmt).scalar_one() @@ -106,19 +125,23 @@ def paginate( async def paginate_async( self, request: Request, - qs: Any, + qs: Select[Any], session: AsyncSession, page_size: int, ) -> tuple[list[Any], int]: - """Async mirror of paginate(); uses await session.execute().""" - page = max(1, int(request.query_params.get(PAGE_PARAM, 1))) - offset = (page - 1) * page_size - count_stmt = select(func.count()).select_from(qs.subquery()) - total: int = (await session.execute(count_stmt)).scalar_one() - rows = ( - (await session.execute(qs.limit(page_size).offset(offset))).scalars().all() + """paginate() for an AsyncSession.""" + return await session.run_sync( + lambda sync_session: self.paginate(request, qs, sync_session, page_size) ) - return list(rows), total + + @staticmethod + def _current_page(request: Request) -> int: + """The requested page, clamped to at least 1; a non-numeric value is page 1.""" + try: + page = int(request.query_params.get(PAGE_PARAM, 1)) + except ValueError: + return 1 + return max(1, page) def wrap( self, @@ -140,84 +163,67 @@ def wrap( params[PAGE_PARAM] = str(page - 1) prev_url = base + "?" + "&".join(f"{k}={v}" for k, v in params.items()) return { - RESPONSE_KEY_COUNT: total, - RESPONSE_KEY_PAGES: pages, - RESPONSE_KEY_NEXT: next_url, - RESPONSE_KEY_PREVIOUS: prev_url, - RESPONSE_KEY_RESULTS: results, + ResponseKey.COUNT: total, + ResponseKey.PAGES: pages, + ResponseKey.NEXT: next_url, + ResponseKey.PREVIOUS: prev_url, + ResponseKey.RESULTS: results, } class CursorPaginator: """Keyset cursor-based paginator using base64(json({"id": last_id})).""" + def __init__(self, page_size: int = DEFAULT_PAGE_SIZE) -> None: + self.page_size = page_size + + def render( + self, + request: Request, + qs: Select[Any], + session: Session, + serialize: RowSerializer, + ) -> dict[str, Any]: + rows, next_cursor = self.paginate(request, qs, session, self.page_size) + return self.wrap([serialize(row) for row in rows], next_cursor, None) + def paginate( self, request: Request, - qs: Any, + qs: Select[Any], session: Session, page_size: int, ) -> tuple[list[Any], str | None]: - cursor_str = request.query_params.get(CURSOR_PARAM) - if cursor_str: + cursor_text = request.query_params.get(CURSOR_PARAM) + if cursor_text: try: - last_id = decode_cursor(cursor_str) - # Extract entity from the select and filter on its id column - entity = ( - qs.columns_clause_froms[0] - if hasattr(qs, "columns_clause_froms") - else None - ) - id_col = None - if entity is not None: - id_col = entity.c.get("id") - if id_col is not None: - qs = qs.where(id_col > last_id) - except Exception: - pass + cursor: Cursor | None = Cursor.decode(cursor_text) + except Cursor.INVALID: + cursor = None + # The id column of the entity being selected, when the query has one. + froms = getattr(qs, "columns_clause_froms", ()) + id_col = froms[0].c.get("id") if froms else None + if cursor is not None and id_col is not None: + qs = qs.where(id_col > cursor.last_id) rows = session.execute(qs.order_by("id").limit(page_size)).scalars().all() next_cursor = None if len(rows) == page_size: - last_obj = rows[-1] - last_row_id = getattr(last_obj, "id", None) + last_row_id = getattr(rows[-1], "id", None) if last_row_id is not None: - next_cursor = encode_cursor(last_row_id) + next_cursor = Cursor(last_row_id).encode() return list(rows), next_cursor async def paginate_async( self, request: Request, - qs: Any, + qs: Select[Any], session: AsyncSession, page_size: int, ) -> tuple[list[Any], str | None]: - """Async mirror of paginate(); uses await session.execute().""" - cursor_str = request.query_params.get(CURSOR_PARAM) - if cursor_str: - try: - last_id = decode_cursor(cursor_str) - entity = ( - qs.columns_clause_froms[0] - if hasattr(qs, "columns_clause_froms") - else None - ) - id_col = None - if entity is not None: - id_col = entity.c.get("id") - if id_col is not None: - qs = qs.where(id_col > last_id) - except Exception: - pass - rows = ( - (await session.execute(qs.order_by("id").limit(page_size))).scalars().all() + """paginate() for an AsyncSession.""" + return await session.run_sync( + lambda sync_session: self.paginate(request, qs, sync_session, page_size) ) - next_cursor = None - if len(rows) == page_size: - last_obj = rows[-1] - last_row_id = getattr(last_obj, "id", None) - if last_row_id is not None: - next_cursor = encode_cursor(last_row_id) - return list(rows), next_cursor def wrap( self, @@ -226,7 +232,7 @@ def wrap( prev_cursor: str | None, ) -> dict[str, Any]: return { - RESPONSE_KEY_NEXT: next_cursor, - RESPONSE_KEY_PREVIOUS: prev_cursor, - RESPONSE_KEY_RESULTS: results, + ResponseKey.NEXT: next_cursor, + ResponseKey.PREVIOUS: prev_cursor, + ResponseKey.RESULTS: results, } diff --git a/lightapi/queryset.py b/lightapi/queryset.py deleted file mode 100644 index 3d59cde..0000000 --- a/lightapi/queryset.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Queryset resolver and filtering for endpoints.""" - -from typing import Any - -from sqlalchemy import select as sa_select -from starlette.requests import Request - - -class QuerysetResolver: - def get_sync(self, endpoint: Any, request: Request) -> Any: - """Resolve queryset for sync endpoints.""" - cls = type(endpoint) - qs_attr = cls.__dict__.get("queryset") - if qs_attr is None: - qs_attr = getattr(cls, "queryset", None) - if qs_attr is None: - return sa_select(cls._model_class) - if callable(qs_attr): - return qs_attr(endpoint, request) - return qs_attr - - async def get_async(self, endpoint: Any, request: Request) -> Any: - """Resolve queryset for async endpoints.""" - import asyncio - - cls = type(endpoint) - qs_attr = cls.__dict__.get("queryset") - if qs_attr is None: - qs_attr = getattr(cls, "queryset", None) - if qs_attr is None: - return sa_select(cls._model_class) - if asyncio.iscoroutinefunction(qs_attr): - result = await qs_attr(endpoint, request) - return result - if callable(qs_attr): - return qs_attr(endpoint, request) - return qs_attr - - -class FilterRunner: - def run(self, endpoint: Any, request: Request, qs: Any) -> Any: - """Run filter backends on queryset.""" - filtering = endpoint._meta.get("filtering") - if not filtering or not filtering.backends: - return qs - for backend_cls in filtering.backends: - qs = backend_cls().filter_queryset(request, qs, endpoint) - return qs - - -class RowSerializer: - def serialize(self, endpoint: Any, row: Any, method: str) -> dict[str, Any]: - """Serialize a database row to response dict.""" - from lightapi.schema import _apply_fields, _row_to_dict, resolve_fields - - cls = type(endpoint) - d = _row_to_dict(row) - fields = resolve_fields(cls, method) - d = _apply_fields(d, fields) - schema = cls.__schema_read__ - validated = schema.model_validate(d) - result = validated.model_dump(mode="json") - if fields is not None: - result = {k: v for k, v in result.items() if k in fields} - return result diff --git a/lightapi/rate_limiter.py b/lightapi/rate_limiter.py index b284b5c..bed5258 100644 --- a/lightapi/rate_limiter.py +++ b/lightapi/rate_limiter.py @@ -4,11 +4,34 @@ import time from collections import defaultdict +from enum import Enum +from http import HTTPStatus from starlette.requests import Request from starlette.responses import JSONResponse +class RateLimitWindow(str, Enum): + """A sliding window of the limiter; the value names it in store keys and headers.""" + + MINUTE = "minute" + HOUR = "hour" + DAY = "day" + + @property + def seconds(self) -> int: + if self is RateLimitWindow.HOUR: + return 3600 + if self is RateLimitWindow.DAY: + return 86400 + return 60 + + @classmethod + def from_key(cls, key: str) -> RateLimitWindow: + """The window of a store key such as "auth:minute" or "minute".""" + return cls(key.rsplit(":", 1)[-1]) + + class RateLimiter: """ Simple in-memory rate limiter. @@ -26,13 +49,22 @@ def __init__( self.requests_per_hour = requests_per_hour self.requests_per_day = requests_per_day - # Storage: {ip: {window: {timestamp: count}}} + # Request counts per client IP, per window name, keyed by timestamp. self._store: dict[str, dict[str, dict[float, int]]] = defaultdict( lambda: defaultdict(lambda: defaultdict(int)) ) self._cleanup_interval = 300 # Cleanup every 5 minutes self._last_cleanup = time.time() + @classmethod + def from_limits( + cls, limits: dict[str, int], defaults: dict[str, int] + ) -> RateLimiter: + """A limiter from ``requests_per_*`` keys; a missing key takes its default.""" + return cls( + **{name: limits.get(name, default) for name, default in defaults.items()} + ) + def reset(self) -> None: """Reset all rate limit tracking. Useful for testing.""" self._store.clear() @@ -66,18 +98,15 @@ def _cleanup_old_entries(self) -> None: self._last_cleanup = current_time def _get_window_seconds(self, window: str) -> int: - """Convert window name to seconds.""" - # Extract window name from key (e.g., "auth:minute" -> "minute") - window_name = window.split(":")[-1] if ":" in window else window + """Seconds covered by a store key such as "auth:minute".""" + return RateLimitWindow.from_key(window).seconds - if window_name == "minute": - return 60 - elif window_name == "hour": - return 3600 - elif window_name == "day": - return 86400 - else: - return 60 + def _limit_for(self, window: RateLimitWindow) -> int: + if window is RateLimitWindow.HOUR: + return self.requests_per_hour + if window is RateLimitWindow.DAY: + return self.requests_per_day + return self.requests_per_minute def _get_client_ip(self, request: Request) -> str: """Extract client IP from request.""" @@ -92,7 +121,7 @@ def _get_client_ip(self, request: Request) -> str: if ip: return ip - return "0.0.0.0" + return "0.0.0.0" # noqa: S104 the bucket for an unknown client, not a bind def is_rate_limited( self, request: Request, endpoint: str = "" @@ -113,26 +142,18 @@ def is_rate_limited( client_ip = self._get_client_ip(request) current_time = time.time() - # Check each window - windows = [ - ("minute", self.requests_per_minute), - ("hour", self.requests_per_hour), - ("day", self.requests_per_day), - ] - - for window_name, limit in windows: - window_seconds = self._get_window_seconds(window_name) - window_key = f"{endpoint}:{window_name}" if endpoint else window_name + for window in RateLimitWindow: + window_key = f"{endpoint}:{window.value}" if endpoint else window.value # Count requests in this window count = 0 for timestamp, request_count in self._store[client_ip][window_key].items(): - if current_time - timestamp < window_seconds: + if current_time - timestamp < window.seconds: count += request_count - if count >= limit: + if count >= self._limit_for(window): # Don't count this request since it's being blocked - return (True, window_name) + return (True, window.value) # Add current request self._store[client_ip][window_key][current_time] = ( @@ -142,33 +163,27 @@ def is_rate_limited( return (False, None) def get_rate_limit_response( - self, request: Request, window: str = "minute" + self, + request: Request, # noqa: ARG002 public signature + window: str = RateLimitWindow.MINUTE.value, ) -> JSONResponse: """Get standard rate limit exceeded response.""" - # Determine window-specific values - if window == "hour": - limit = self.requests_per_hour - retry_after = 3600 - reset_seconds = 3600 - elif window == "day": - limit = self.requests_per_day - retry_after = 86400 - reset_seconds = 86400 - else: # minute - limit = self.requests_per_minute - retry_after = 60 - reset_seconds = 60 + try: + exceeded = RateLimitWindow(window) + except ValueError: + # An unknown window name has always been answered as the minute window. + exceeded = RateLimitWindow.MINUTE return JSONResponse( { "error": "rate_limit_exceeded", "detail": "Too many requests. Please try again later.", }, - status_code=429, + status_code=HTTPStatus.TOO_MANY_REQUESTS, headers={ - "Retry-After": str(retry_after), - "X-RateLimit-Limit": str(limit), + "Retry-After": str(exceeded.seconds), + "X-RateLimit-Limit": str(self._limit_for(exceeded)), "X-RateLimit-Remaining": "0", - "X-RateLimit-Reset": str(int(time.time() + reset_seconds)), + "X-RateLimit-Reset": str(int(time.time() + exceeded.seconds)), }, ) diff --git a/lightapi/repository.py b/lightapi/repository.py new file mode 100644 index 0000000..24a7d5d --- /dev/null +++ b/lightapi/repository.py @@ -0,0 +1,154 @@ +"""Row persistence for one mapped endpoint class.""" + +from __future__ import annotations + +import datetime +import logging +from typing import Any, Final, Protocol + +from sqlalchemy import delete, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, class_mapper + +from lightapi.constants import INT64_MAX, INT64_MIN + +FIRST_VERSION: Final = 1 +logger = logging.getLogger(__name__) + + +class RowNotFound(Exception): + """No row has the requested primary key.""" + + +class VersionConflict(Exception): + """The row exists, but at a different ``version`` than the caller sent.""" + + +class ConstraintViolation(Exception): + """The database refused the write: a unique, foreign-key or check constraint.""" + + +class VersionedRow(Protocol): + """What every LightAPI table has: a primary key and a version counter.""" + + id: Any + version: Any + + def __init__(self, **columns: Any) -> None: ... # noqa: ANN401 column values + + +class Repository: + """Reads and writes rows through an open Session. + + Owns the two storage rules of a LightAPI table: the ``created_at`` / + ``updated_at`` timestamps and optimistic locking on ``version``. + """ + + # SQLite (and every backend LightAPI supports) stores an id in a signed + # 64-bit column; a pk outside this range can never match a row, so treat it + # as not-found instead of letting the driver raise OverflowError. + _MIN_PK: Final = INT64_MIN + _MAX_PK: Final = INT64_MAX + + def __init__(self, model: type[VersionedRow], session: Session) -> None: + self._model = model + self._session = session + + @classmethod + def _in_pk_range(cls, pk: int) -> bool: + return cls._MIN_PK <= pk <= cls._MAX_PK + + @staticmethod + def utcnow() -> datetime.datetime: + """Naive UTC, which is what the DateTime columns store and default to.""" + return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + + @property + def nullable_columns(self) -> set[str]: + # class_mapper() and column_attrs are memoised by SQLAlchemy; caching the + # result per class here (functools.cache) pinned every endpoint class and + # its engine for the life of the process. + return { + attr.key + for attr in class_mapper(self._model).column_attrs + if any(column.nullable for column in attr.columns) + } + + def get(self, pk: int) -> VersionedRow: + if not self._in_pk_range(pk): + raise RowNotFound(pk) + model = self._model + row = ( + self._session.execute(select(model).where(model.id == pk)).scalars().first() + ) + if row is None: + raise RowNotFound(pk) + return row + + def add(self, values: dict[str, Any]) -> VersionedRow: + now = self.utcnow() + row = self._model( + **values, created_at=now, updated_at=now, version=FIRST_VERSION + ) + self._session.add(row) + try: + self._session.flush() # executes INSERT, populates auto-increment id + except IntegrityError as exc: + raise self._refused(exc) from exc + self._session.refresh(row) # re-loads DB-generated columns + return row + + def _refused(self, exc: IntegrityError) -> ConstraintViolation: + """Roll back and turn the database's refusal into a ConstraintViolation. + + Logged with the table and the driver's text: a unique clash is a client + conflict, but a NOT NULL or CHECK failure usually means the schema and the + table disagree, and the operator should see that even though the client + only gets a 409. + """ + self._session.rollback() + logger.info( + "Constraint violation on %s: %s", + class_mapper(self._model).local_table, + exc.orig, + ) + return ConstraintViolation(str(exc.orig)) + + def update( + self, pk: int, expected_version: int, values: dict[str, Any] + ) -> VersionedRow: + """Write ``values`` only if the row is still at ``expected_version``.""" + if not self._in_pk_range(pk): + raise RowNotFound(pk) + model = self._model + try: + result = self._session.execute( + update(model) + .where(model.id == pk, model.version == expected_version) + .values( + **values, version=expected_version + 1, updated_at=self.utcnow() + ) + ) + except IntegrityError as exc: + raise self._refused(exc) from exc + if result.rowcount == 0: + exists = self._session.execute( + select(model.id).where(model.id == pk) + ).first() + self._session.rollback() + raise VersionConflict(pk) if exists else RowNotFound(pk) + # Re-fetch so every column, updated_at and version included, is current. + return self.get(pk) + + def delete(self, pk: int) -> None: + if not self._in_pk_range(pk): + raise RowNotFound(pk) + model = self._model + try: + deleted = self._session.execute( + delete(model).where(model.id == pk).returning(model.id) + ).first() + except IntegrityError as exc: + raise self._refused(exc) from exc + if deleted is None: + raise RowNotFound(pk) diff --git a/lightapi/response_wrapper.py b/lightapi/response_wrapper.py deleted file mode 100644 index 93b89d2..0000000 --- a/lightapi/response_wrapper.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Response wrapper for handling dict responses.""" - -from typing import Any - -from starlette.responses import JSONResponse, Response - - -def wrap_dict_response(result: Any) -> Response: - """Wrap dict responses in JSONResponse.""" - if isinstance(result, dict): - return JSONResponse(result) - return result diff --git a/lightapi/rest.py b/lightapi/rest.py index 154ff8e..aebcec0 100644 --- a/lightapi/rest.py +++ b/lightapi/rest.py @@ -2,81 +2,54 @@ from __future__ import annotations -import asyncio -import datetime +import inspect from collections.abc import Callable -from decimal import Decimal -from typing import TYPE_CHECKING, Any, get_args, get_origin -from uuid import UUID +from functools import partial +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, ClassVar, Final, cast if TYPE_CHECKING: + from sqlalchemy import Engine, Select + from sqlalchemy.ext.asyncio import AsyncEngine + from sqlalchemy.orm import Session from starlette.background import BackgroundTasks -from sqlalchemy import ( - Boolean, - Column, - DateTime, - Float, - ForeignKey, - Integer, - Numeric, - String, - delete, - update, -) +from pydantic import BaseModel, TypeAdapter, ValidationError from sqlalchemy import select as sa_select -from sqlalchemy.dialects.postgresql import UUID as PG_UUID - -try: - from sqlalchemy import Uuid as SAUuid # SQLAlchemy 2.0+ -except ImportError: - SAUuid = None # type: ignore[assignment,misc] - from starlette.requests import Request from starlette.responses import JSONResponse, Response from lightapi.constants import ( AUTO_FIELDS, - RESPONSE_KEY_DETAIL, - RESPONSE_KEY_RESULTS, - HTTPStatus, + CRUD_VERBS, + ApiError, + HttpVerb, + TableReflection, ) from lightapi.exceptions import ConfigurationError -from lightapi.schema import ( - SchemaFactory, - _apply_fields, - _row_to_dict, - normalise_serializer, - resolve_fields, +from lightapi.filters import FilterValueError +from lightapi.pagination import NoPagination +from lightapi.repository import ( + ConstraintViolation, + Repository, + RowNotFound, + VersionConflict, ) +from lightapi.schema import FieldProjection, SchemaFactory +from lightapi.session import get_async_session, get_sync_session +from lightapi.session_manager import SessionManager +from lightapi.table_mapping import DeclaredTable, ReflectedTable, TableSource _AUTO_FIELDS = AUTO_FIELDS -_TYPE_MAP: dict[Any, Any] = { - str: String, - int: Integer, - float: Float, - bool: Boolean, - datetime.datetime: DateTime, - Decimal: Numeric, - UUID: SAUuid if SAUuid is not None else PG_UUID, -} - -_ALL_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +# The client's `version` arrives as a raw JSON value (int, numeric string, or +# whole float); this coerces it the same way pydantic would validate an int +# field, and rejects anything else with a 422 instead of a TypeError deep in +# the repository's `expected_version + 1` arithmetic. +_VERSION_ADAPTER: TypeAdapter[int] = TypeAdapter(int) - -def _is_optional(annotation: Any) -> tuple[bool, Any]: - """Return (is_optional, inner_type) for an annotation.""" - import types as _types - import typing - - origin = get_origin(annotation) - args = get_args(annotation) - if origin is typing.Union or origin is _types.UnionType: # type: ignore[attr-defined] - non_none = [a for a in args if a is not type(None)] - if len(non_none) == 1 and type(None) in args: - return True, non_none[0] - return False, annotation +_NO_PAGINATION: Final = NoPagination() +_NO_PROJECTION: Final = FieldProjection(None, None, None) class RestEndpointMeta(type): @@ -87,184 +60,98 @@ def __new__( name: str, bases: tuple[type, ...], namespace: dict[str, Any], - **kwargs: Any, + **kwargs: Any, # noqa: ANN401 class keyword arguments (base_only=...) ) -> type: - cls = super().__new__(mcs, name, bases, namespace, **kwargs) + # Every class made here is RestEndpoint or a subclass; the metaclass fills + # in the class attributes RestEndpoint only declares. + cls = cast( + "type[RestEndpoint]", super().__new__(mcs, name, bases, namespace, **kwargs) + ) - if name == "RestEndpoint": + if not bases: + # RestEndpoint itself: nothing to map. return cls is_base_only = kwargs.get("base_only", False) or namespace.get( "_base_only", False ) if is_base_only: - cls._allowed_methods = set(_ALL_METHODS) + cls._allowed_methods = {verb.value for verb in CRUD_VERBS} cls._meta = {} - cls._fields_info = {} return cls mcs._process(cls, name, namespace) return cls - @staticmethod - def _process(cls: type, name: str, namespace: dict[str, Any]) -> None: - import typing as _typing + @classmethod + def _process( + mcs, cls: type[RestEndpoint], name: str, namespace: dict[str, Any] + ) -> None: + mcs._reject_redeclared_auto_fields(name, namespace) - from pydantic.fields import FieldInfo + meta_obj = namespace.get("Meta") or getattr(cls, "Meta", None) + reflection = TableReflection.from_meta(getattr(meta_obj, "reflect", None)) - # ── Step 1: Collect annotations ────────────────────────────────────── - # Use get_type_hints() so that PEP-563 string annotations (from - # `from __future__ import annotations`) are resolved to real types. - try: - resolved = _typing.get_type_hints(cls) - except Exception: - resolved = {} - - annotations: dict[str, Any] = {} - for base in reversed(cls.__mro__): - for k, v in getattr(base, "__annotations__", {}).items(): - if not k.startswith("_") and k not in _AUTO_FIELDS: - # Prefer the resolved type; fall back to the raw annotation. - annotations[k] = resolved.get(k, v) - - # Remove fields inherited from RestEndpoint itself - if "RestEndpoint" in [b.__name__ for b in cls.__mro__[1:]]: - for b in cls.__mro__[1:]: - if b.__name__ == "RestEndpoint": - for k in list(annotations): - if k in getattr(b, "__annotations__", {}): - annotations.pop(k, None) - break - - # Guard: user must not redeclare auto-injected fields - for auto in _AUTO_FIELDS: + cls._meta = mcs._parsed_meta(name, meta_obj) + cls._allowed_methods = mcs._methods_from_mixins(cls) + + if reflection is not None: + # ReflectedTable.map() builds the schemas once the columns are known, + # at register(); until then they are None, as they always were. + cls.__schema_create__ = None # type: ignore[assignment] + cls.__schema_read__ = None # type: ignore[assignment] + cls._table_source = ReflectedTable(reflection) + return + + cls.__schema_create__, cls.__schema_read__ = SchemaFactory.build(cls) + cls._table_source = DeclaredTable.from_endpoint(cls, name, namespace) + + @staticmethod + def _reject_redeclared_auto_fields(name: str, namespace: dict[str, Any]) -> None: + for auto in AUTO_FIELDS: if auto in namespace.get("__annotations__", {}): raise ConfigurationError( f"RestEndpoint '{name}': '{auto}' is auto-injected " "and must not be redeclared." ) - # ── Step 2: Build SQLAlchemy columns ───────────────────────────────── - columns: list[Column] = [] - fields_info: dict[str, FieldInfo] = {} - - meta_class = namespace.get("Meta") or getattr(cls, "Meta", None) - reflect = getattr(meta_class, "reflect", False) if meta_class else False - - if not reflect: - for field_name, annotation in annotations.items(): - field_val = namespace.get(field_name) or getattr(cls, field_name, None) - fi = field_val if isinstance(field_val, FieldInfo) else None - if fi: - fields_info[field_name] = fi - - extra: dict[str, Any] = (fi.json_schema_extra or {}) if fi else {} - if extra.get("exclude"): - continue - - is_opt, inner = _is_optional(annotation) - col_type = _TYPE_MAP.get(inner) - if col_type is None: - raise ConfigurationError( - f"RestEndpoint '{name}': annotation '{inner}' on field " - f"'{field_name}' is not in the type map. " - "Add exclude=True to skip column generation." - ) - - col_kwargs: dict[str, Any] = {"nullable": is_opt} - col_args: list[Any] = [] - - if inner is Decimal: - scale = extra.get("decimal_places", 10) - col_type = Numeric(scale=scale) - - if extra.get("foreign_key"): - col_args.append(ForeignKey(extra["foreign_key"])) - if extra.get("unique"): - col_kwargs["unique"] = True - if extra.get("index"): - col_kwargs["index"] = True - - columns.append(Column(field_name, col_type, *col_args, **col_kwargs)) - - # ── Step 3: Auto-inject id / created_at / updated_at / version ─────── - auto_cols = [ - Column("id", Integer, primary_key=True, autoincrement=True), - Column("created_at", DateTime, default=datetime.datetime.utcnow), - Column( - "updated_at", - DateTime, - default=datetime.datetime.utcnow, - onupdate=datetime.datetime.utcnow, - ), - Column("version", Integer, default=1, nullable=False), - ] - - # ── Step 4: SchemaFactory ───────────────────────────────────────────── - if reflect is True or reflect == "full" or reflect == "partial": - cls.__schema_create__ = None # type: ignore[attr-defined] - cls.__schema_read__ = None # type: ignore[attr-defined] - cls._schema_deferred = True # type: ignore[attr-defined] - else: - cls.__schema_create__, cls.__schema_read__ = SchemaFactory.build(cls) # type: ignore[attr-defined] - cls._schema_deferred = False # type: ignore[attr-defined] - - # ── Step 5: Parse Meta → _meta ──────────────────────────────────────── - meta_obj = namespace.get("Meta") or getattr(cls, "Meta", None) + @staticmethod + def _parsed_meta(name: str, meta_obj: type | None) -> dict[str, Any]: raw_serializer = getattr(meta_obj, "serializer", None) if meta_obj else None - # Guard: Meta.serializer must be Serializer instance/subclass - if raw_serializer is not None: - from pydantic import BaseModel as PydanticBaseModel - - if isinstance(raw_serializer, type): - if issubclass(raw_serializer, PydanticBaseModel): - raise ConfigurationError( - f"Meta.serializer on '{name}' must be a Serializer " - "instance or subclass, not a BaseModel subclass." - ) - serialiser_normalised = normalise_serializer(raw_serializer) - - cls._meta = { # type: ignore[attr-defined] - "authentication": ( - getattr(meta_obj, "authentication", None) if meta_obj else None - ), - "filtering": getattr(meta_obj, "filtering", None) if meta_obj else None, - "pagination": getattr(meta_obj, "pagination", None) if meta_obj else None, - "serializer_normalised": serialiser_normalised, - "cache": getattr(meta_obj, "cache", None) if meta_obj else None, - "reflect": getattr(meta_obj, "reflect", False) if meta_obj else False, - "table": getattr(meta_obj, "table", None) if meta_obj else None, - } - cls._fields_info = fields_info # type: ignore[attr-defined] - - # ── Step 6: MRO scan for HttpMethod markers ─────────────────────────── + # FieldProjection rejects every non-Serializer; a pydantic model gets its + # own message because it is the mistake people actually make. + if isinstance(raw_serializer, type) and issubclass(raw_serializer, BaseModel): + raise ConfigurationError( + f"Meta.serializer on '{name}' must be a Serializer " + "instance or subclass, not a BaseModel subclass." + ) - allowed: set[str] = set() - for base in cls.__mro__: - if base is cls: - continue - http_method = getattr(base, "_http_method", None) - if http_method: - allowed.add(http_method) - cls._allowed_methods = allowed if allowed else set(_ALL_METHODS) # type: ignore[attr-defined] + return { + "authentication": getattr(meta_obj, "authentication", None), + "filtering": getattr(meta_obj, "filtering", None), + "pagination": getattr(meta_obj, "pagination", None), + "serializer_normalised": FieldProjection.from_serializer(raw_serializer), + "cache": getattr(meta_obj, "cache", None), + "table": getattr(meta_obj, "table", None), + } - # ── Step 7: Imperative SQLAlchemy mapping ───────────────────────────── - if reflect is True or reflect == "full" or reflect == "partial": - # Defer reflection until LightApi.register() when an engine is available. - cls._reflect_deferred = True # type: ignore[attr-defined] - cls._reflect_partial_columns = columns if reflect == "partial" else [] # type: ignore[attr-defined] - else: - cls._reflect_deferred = False # type: ignore[attr-defined] - all_columns = auto_cols + columns - # Store columns for potential re-mapping during registration - cls._all_columns = all_columns # type: ignore[attr-defined] + @staticmethod + def _methods_from_mixins(cls: type[RestEndpoint]) -> set[str]: + """HTTP methods named by HttpMethod mixins in the MRO; all when none is.""" + allowed = { + HttpVerb(method).value + for base in cls.__mro__[1:] + if (method := getattr(base, "_http_method", None)) + } + return allowed or {verb.value for verb in CRUD_VERBS} - # Determine table name for test isolation - table_name = getattr(meta_obj, "table", None) or f"{name.lower()}s" - # Store table name for potential test isolation during registration - cls._test_isolation_table_name = table_name # type: ignore[attr-defined] +_API_ERRORS: Final[dict[type[Exception], ApiError]] = { + RowNotFound: ApiError.NOT_FOUND, + VersionConflict: ApiError.VERSION_CONFLICT, + ConstraintViolation: ApiError.CONSTRAINT_VIOLATION, +} class RestEndpoint(metaclass=RestEndpointMeta): @@ -274,12 +161,17 @@ class RestEndpoint(metaclass=RestEndpointMeta): The metaclass auto-generates SQLAlchemy columns and Pydantic schemas. """ - _model_class: type + _model_class: type | None = None + _table_source: TableSource _meta: dict[str, Any] _allowed_methods: set[str] - _fields_info: dict[str, Any] + # Built by the metaclass; a reflected table gets them at register(). + __schema_create__: ClassVar[type[BaseModel]] + __schema_read__: ClassVar[type[BaseModel]] + # Bound by LightApi.register(); None until then. + _session_manager: ClassVar[SessionManager | None] = None - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 column values self._background: BackgroundTasks | None = None self._current_request: Request | None = None for k, v in kwargs.items(): @@ -287,7 +179,12 @@ def __init__(self, **kwargs: Any) -> None: # ── Background task support ─────────────────────────────────────────────── - def background(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None: + def background( + self, + fn: Callable[..., Any], + *args: Any, # noqa: ANN401 passed through to fn + **kwargs: Any, # noqa: ANN401 passed through to fn + ) -> None: """Schedule fn as a fire-and-forget background task for the current request.""" if self._background is None: raise RuntimeError("background() called outside request handler") @@ -295,40 +192,40 @@ def background(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None: # ── CRUD helpers ────────────────────────────────────────────────────────── - def _get_engine(self) -> Any: - cls = type(self) - session_manager = getattr(cls, "_session_manager", None) - + def _session_manager_or_raise(self) -> SessionManager: + """The SessionManager register() bound to this endpoint class.""" + session_manager = self._session_manager if session_manager is None: raise RuntimeError( "No session_manager configured. " "Ensure LightApi.register() was called with a properly configured app." ) + return session_manager - engine = session_manager.engine - - # Sync callers use the sync engine; if an AsyncEngine was registered, unwrap it. - try: - from sqlalchemy.ext.asyncio import AsyncEngine as _AE + def _get_engine(self) -> Engine: + """The engine for sync CRUD; an AsyncEngine is unwrapped to its sync one.""" + return self._session_manager_or_raise().sync_engine - if isinstance(engine, _AE): - return engine.sync_engine - except ImportError: - pass - return engine + @classmethod + def _model_class_or_raise(cls) -> type: + """The mapped class; LightApi.register() maps it.""" + if cls._model_class is None: + raise RuntimeError( + f"{cls.__name__} is not mapped to a table. " + "Ensure LightApi.register() was called with it." + ) + return cls._model_class - def _get_queryset(self, request: Request) -> Any: + def _get_queryset(self, request: Request) -> Any: # noqa: ANN401 a Select, or the coroutine of an async def queryset cls = type(self) - qs_attr = cls.__dict__.get("queryset") + qs_attr = getattr(cls, "queryset", None) if qs_attr is None: - qs_attr = getattr(cls, "queryset", None) - if qs_attr is None: - return sa_select(cls._model_class) + return sa_select(cls._model_class_or_raise()) if callable(qs_attr): return qs_attr(self, request) return qs_attr - def _run_filter_backends(self, request: Request, qs: Any) -> Any: + def _run_filter_backends(self, request: Request, qs: Select[Any]) -> Select[Any]: filtering = self._meta.get("filtering") if not filtering or not filtering.backends: return qs @@ -336,484 +233,187 @@ def _run_filter_backends(self, request: Request, qs: Any) -> Any: qs = backend_cls().filter_queryset(request, qs, self) return qs - def _serialize_row(self, row: Any, method: str) -> dict[str, Any]: - cls = type(self) - d = _row_to_dict(row) - fields = resolve_fields(cls, method) - d = _apply_fields(d, fields) - schema = cls.__schema_read__ - validated = schema.model_validate(d) + def _fields_for(self, verb: HttpVerb) -> list[str] | None: + """The serializer's field list for a verb, resolved once per request.""" + projection: FieldProjection = self._meta.get( + "serializer_normalised", _NO_PROJECTION + ) + return projection.for_method(verb) + + def _serialize_row(self, row: object, fields: list[str] | None) -> dict[str, Any]: + d = FieldProjection.apply(SchemaFactory.row_to_dict(row), fields) + validated = type(self).__schema_read__.model_validate(d) result = validated.model_dump(mode="json") # Re-apply projection so Optional fields that aren't in the serializer # list don't bleed through as null in the response. - if fields is not None: - result = {k: v for k, v in result.items() if k in fields} - return result + return FieldProjection.apply(result, fields) - def list(self, request: Request) -> Response: - """Handle GET /{path} — return collection.""" - from sqlalchemy.orm import Session + # ── CRUD core: one implementation, always given an open sync Session ───── - engine = self._get_engine() - pagination_cfg = self._meta.get("pagination") - - with Session(engine) as session: - qs = self._get_queryset(request) + def _list(self, session: Session, request: Request, qs: Select[Any]) -> Response: + pagination = self._meta.get("pagination") + paginator = pagination.paginator if pagination else _NO_PAGINATION + try: qs = self._run_filter_backends(request, qs) + except FilterValueError as exc: + return ApiError.validation_failed(exc.errors()) + serialize = partial(self._serialize_row, fields=self._fields_for(HttpVerb.GET)) + return JSONResponse(paginator.render(request, qs, session, serialize)) - if pagination_cfg: - from lightapi.pagination import CursorPaginator, PageNumberPaginator - - if pagination_cfg.style == "cursor": - pager = CursorPaginator() - rows, next_cursor = pager.paginate( - request, qs, session, pagination_cfg.page_size - ) - results = [self._serialize_row(r, "GET") for r in rows] - return JSONResponse(pager.wrap(results, next_cursor, None)) - else: - pager = PageNumberPaginator() - page = int(request.query_params.get("page", 1)) - rows, total = pager.paginate( - request, qs, session, pagination_cfg.page_size - ) - results = [self._serialize_row(r, "GET") for r in rows] - return JSONResponse( - pager.wrap( - request, results, total, page, pagination_cfg.page_size - ) - ) - - instances = session.execute(qs).scalars().all() - results = [self._serialize_row(inst, "GET") for inst in instances] - return JSONResponse({RESPONSE_KEY_RESULTS: results}) - - def retrieve(self, request: Request, pk: int) -> Response: - """Handle GET /{path}/{id}.""" - from sqlalchemy.orm import Session - - engine = self._get_engine() - cls = type(self) - with Session(engine) as session: - instance = ( - session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - .scalars() - .first() - ) - if instance is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - return JSONResponse(self._serialize_row(instance, "GET")) - - def create(self, data: dict[str, Any]) -> Response: - """Handle POST /{path} — validate input and insert row.""" - from pydantic import ValidationError - from sqlalchemy.orm import Session + def _retrieve(self, session: Session, pk: int) -> Response: + try: + row = self._repository(session).get(pk) + except RowNotFound as exc: + return _API_ERRORS[type(exc)].response() + return JSONResponse(self._serialize_row(row, self._fields_for(HttpVerb.GET))) - engine = self._get_engine() - cls = type(self) + def _create(self, session: Session, data: dict[str, Any]) -> Response: try: - validated = cls.__schema_create__.model_validate(data) + validated = type(self).__schema_create__.model_validate(data) except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) + return ApiError.validation_failed(exc.errors()) - with Session(engine) as session: - now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - instance = cls._model_class( - **validated.model_dump(), - created_at=now, - updated_at=now, - version=1, - ) - session.add(instance) - session.flush() # executes INSERT, populates auto-increment id - session.refresh( - instance - ) # re-loads all columns (including DB-generated ones) - response_data = self._serialize_row(instance, "POST") - session.commit() - return JSONResponse(response_data, status_code=HTTPStatus.CREATED) - - def update(self, data: dict[str, Any], pk: int, partial: bool = False) -> Response: - """Handle PUT/PATCH /{path}/{id} with optimistic locking.""" - from pydantic import ValidationError - from sqlalchemy.orm import Session + try: + row = self._repository(session).add(validated.model_dump()) + except ConstraintViolation as exc: + return _API_ERRORS[type(exc)].response() + return JSONResponse( + self._serialize_row(row, self._fields_for(HttpVerb.POST)), + status_code=HTTPStatus.CREATED, + ) + def _update( + self, session: Session, data: dict[str, Any], pk: int, *, partial: bool + ) -> Response: + """PUT/PATCH; the client must send the ``version`` it last read.""" client_version = data.get("version") if client_version is None: - return JSONResponse( - { - RESPONSE_KEY_DETAIL: [ - {"loc": ["version"], "msg": "Field required", "type": "missing"} - ] - }, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + return ApiError.validation_failed( + [{"loc": ["version"], "msg": "Field required", "type": "missing"}] ) - - engine = self._get_engine() - cls = type(self) - try: - if partial: - # Build a one-shot model where every field is Optional so that - # PATCH can supply any subset of fields without validation errors. - from typing import Optional as _Opt - - from pydantic import ConfigDict as _CD - from pydantic import create_model as _cm - - patch_fields: dict[str, Any] = {} - for fname, finfo in cls.__schema_create__.model_fields.items(): - ann = finfo.annotation - patch_fields[fname] = (_Opt[ann], None) # type: ignore[valid-type] - PatchSchema = _cm( - f"{cls.__name__}PatchSchema", - __config__=_CD(from_attributes=True), - **patch_fields, - ) - validated = PatchSchema.model_validate(data) - # Determine which columns are nullable so explicit null values - # can clear Optional fields (non-nullable fields still skip None). - from sqlalchemy import inspect as _sa_inspect - - nullable_cols: set[str] = { - attr.key - for attr in _sa_inspect(cls._model_class).mapper.column_attrs - if any(c.nullable for c in attr.columns) - } - update_data = { - k: v - for k, v in validated.model_dump(exclude_unset=True).items() - if k not in _AUTO_FIELDS and (v is not None or k in nullable_cols) - } - else: - validated = cls.__schema_create__.model_validate(data) - update_data = { - k: v - for k, v in validated.model_dump().items() - if k not in _AUTO_FIELDS - } + client_version = _VERSION_ADAPTER.validate_python(client_version) except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - update_data.pop("version", None) + return ApiError.validation_failed(exc.errors()) - with Session(engine) as session: - result = session.execute( - update(cls._model_class) - .where( - cls._model_class.id == pk, - cls._model_class.version == client_version, - ) - .values( - **update_data, - version=client_version + 1, - updated_at=datetime.datetime.now(datetime.timezone.utc).replace( - tzinfo=None - ), - ) - ) - if result.rowcount == 0: - exists = session.execute( - sa_select(cls._model_class.id).where(cls._model_class.id == pk) - ).first() - session.rollback() - if not exists: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, - status_code=HTTPStatus.NOT_FOUND, - ) - return JSONResponse( - {RESPONSE_KEY_DETAIL: "version conflict"}, - status_code=HTTPStatus.CONFLICT, - ) - # Re-fetch so all columns (including updated_at/version) are current - instance = ( - session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - .scalars() - .first() + repository = self._repository(session) + try: + values = self._update_values( + data, repository.nullable_columns, partial=partial ) - response_data = self._serialize_row(instance, "PUT") - session.commit() - return JSONResponse(response_data) - - def destroy(self, request: Request, pk: int) -> Response: - """Handle DELETE /{path}/{id}.""" - from sqlalchemy.orm import Session + except ValidationError as exc: + return ApiError.validation_failed(exc.errors()) - engine = self._get_engine() - cls = type(self) - with Session(engine) as session: - stmt = ( - delete(cls._model_class) - .where(cls._model_class.id == pk) - .returning(cls._model_class.id) - ) - result = session.execute(stmt).first() - if result is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - session.commit() - return Response(status_code=HTTPStatus.NO_CONTENT) + try: + row = repository.update(pk, client_version, values) + except (RowNotFound, VersionConflict, ConstraintViolation) as exc: + return _API_ERRORS[type(exc)].response() + return JSONResponse(self._serialize_row(row, self._fields_for(HttpVerb.PUT))) + + def _update_values( + self, data: dict[str, Any], nullable_columns: set[str], *, partial: bool + ) -> dict[str, Any]: + """Validated column values for an update, without the auto-managed fields.""" + schema_create = type(self).__schema_create__ + if not partial: + values = schema_create.model_validate(data).model_dump() + return {k: v for k, v in values.items() if k not in AUTO_FIELDS} + + sent = SchemaFactory.patch_schema(schema_create).model_validate(data) + # An explicit null clears a nullable column; on any other column it is ignored. + return { + k: v + for k, v in sent.model_dump(exclude_unset=True).items() + if k not in AUTO_FIELDS and (v is not None or k in nullable_columns) + } - # ── Async queryset resolver ─────────────────────────────────────────────── + def _destroy(self, session: Session, pk: int) -> Response: + try: + self._repository(session).delete(pk) + except (RowNotFound, ConstraintViolation) as exc: + return _API_ERRORS[type(exc)].response() + return Response(status_code=HTTPStatus.NO_CONTENT) - async def _get_queryset_async(self, request: Request) -> Any: - """Resolve queryset; await if it is a coroutine function.""" - cls = type(self) - qs_attr = cls.__dict__.get("queryset") - if qs_attr is None: - qs_attr = getattr(cls, "queryset", None) - if qs_attr is None: - return sa_select(cls._model_class) - if asyncio.iscoroutinefunction(qs_attr): - result = await qs_attr(self, request) - return result - if callable(qs_attr): - return qs_attr(self, request) - return qs_attr + def _repository(self, session: Session) -> Repository: + return Repository(self._model_class_or_raise(), session) - def _get_async_engine(self) -> Any: - """Return the raw (AsyncEngine) engine for async session creation.""" - cls = type(self) - session_manager = getattr(cls, "_session_manager", None) + # ── Sync CRUD: the core inside a sync Session ───────────────────────────── - if session_manager is None: - raise RuntimeError( - "No session_manager configured. " - "Ensure LightApi.register() was called with a properly configured app." - ) + def list(self, request: Request) -> Response: + """Handle GET /{path} — return collection.""" + with get_sync_session(self._get_engine()) as session: + return self._list(session, request, self._get_queryset(request)) - return session_manager.engine + def retrieve( + self, + request: Request, # noqa: ARG002 public signature + pk: int, + ) -> Response: + """Handle GET /{path}/{id}.""" + with get_sync_session(self._get_engine()) as session: + return self._retrieve(session, pk) - # ── Async CRUD ──────────────────────────────────────────────────────────── + def create(self, data: dict[str, Any]) -> Response: + """Handle POST /{path} — validate input and insert row.""" + with get_sync_session(self._get_engine()) as session: + return self._create(session, data) - async def _list_async(self, request: Request) -> Response: - """Async mirror of list(); uses AsyncSession.""" - from lightapi.session import get_async_session + def update(self, data: dict[str, Any], pk: int, partial: bool = False) -> Response: + """Handle PUT/PATCH /{path}/{id} with optimistic locking.""" + with get_sync_session(self._get_engine()) as session: + return self._update(session, data, pk, partial=partial) - engine = self._get_async_engine() - pagination_cfg = self._meta.get("pagination") + def destroy( + self, + request: Request, # noqa: ARG002 public signature + pk: int, + ) -> Response: + """Handle DELETE /{path}/{id}.""" + with get_sync_session(self._get_engine()) as session: + return self._destroy(session, pk) - async with get_async_session(engine) as session: - qs = await self._get_queryset_async(request) - qs = self._run_filter_backends(request, qs) + # ── Async CRUD: the same core, run through AsyncSession.run_sync ────────── - if pagination_cfg: - from lightapi.pagination import CursorPaginator, PageNumberPaginator - - if pagination_cfg.style == "cursor": - pager = CursorPaginator() - rows, next_cursor = await pager.paginate_async( - request, qs, session, pagination_cfg.page_size - ) - results = [self._serialize_row(r, "GET") for r in rows] - return JSONResponse(pager.wrap(results, next_cursor, None)) - else: - pager = PageNumberPaginator() - page = int(request.query_params.get("page", 1)) - rows, total = await pager.paginate_async( - request, qs, session, pagination_cfg.page_size - ) - results = [self._serialize_row(r, "GET") for r in rows] - return JSONResponse( - pager.wrap( - request, results, total, page, pagination_cfg.page_size - ) - ) - - instances = (await session.execute(qs)).scalars().all() - results = [self._serialize_row(inst, "GET") for inst in instances] - return JSONResponse({"results": results}) - - async def _retrieve_async(self, request: Request, pk: int) -> Response: - """Async mirror of retrieve(); uses AsyncSession.""" - from lightapi.session import get_async_session - - engine = self._get_async_engine() - cls = type(self) - async with get_async_session(engine) as session: - instance = ( - ( - await session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - ) - .scalars() - .first() - ) - if instance is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - return JSONResponse(self._serialize_row(instance, "GET")) + async def _get_queryset_async(self, request: Request) -> Select[Any]: + """_get_queryset(), awaiting the result of an ``async def queryset``.""" + raw = self._get_queryset(request) + qs: Select[Any] = await raw if inspect.isawaitable(raw) else raw + return qs - async def _create_async(self, data: dict[str, Any]) -> Response: - """Async mirror of create(); ORM-style insert with flush/refresh.""" - from pydantic import ValidationError + def _get_async_engine(self) -> AsyncEngine: + """Return the raw (AsyncEngine) engine for async session creation.""" + engine: AsyncEngine = self._session_manager_or_raise().engine + return engine - from lightapi.session import get_async_session + async def _in_async_session(self, work: Callable[[Session], Response]) -> Response: + async with get_async_session(self._get_async_engine()) as session: + return await session.run_sync(work) - engine = self._get_async_engine() - cls = type(self) - try: - validated = cls.__schema_create__.model_validate(data) - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) + async def _list_async(self, request: Request) -> Response: + """Async list(); an ``async def queryset`` is awaited first.""" + qs = await self._get_queryset_async(request) + return await self._in_async_session(lambda s: self._list(s, request, qs)) + + async def _retrieve_async( + self, + request: Request, # noqa: ARG002 mirrors retrieve() + pk: int, + ) -> Response: + return await self._in_async_session(lambda s: self._retrieve(s, pk)) - async with get_async_session(engine) as session: - now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - instance = cls._model_class( - **validated.model_dump(), - created_at=now, - updated_at=now, - version=1, - ) - session.add(instance) - await session.flush() - await session.refresh(instance) - response_data = self._serialize_row(instance, "POST") - return JSONResponse(response_data, status_code=HTTPStatus.CREATED) + async def _create_async(self, data: dict[str, Any]) -> Response: + return await self._in_async_session(lambda s: self._create(s, data)) async def _update_async( self, data: dict[str, Any], pk: int, partial: bool = False ) -> Response: - """Async mirror of update() with optimistic locking.""" - from pydantic import ValidationError - - from lightapi.session import get_async_session - - client_version = data.get("version") - if client_version is None: - return JSONResponse( - { - RESPONSE_KEY_DETAIL: [ - {"loc": ["version"], "msg": "Field required", "type": "missing"} - ] - }, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - engine = self._get_async_engine() - cls = type(self) - - try: - if partial: - from typing import Optional as _Opt - - from pydantic import ConfigDict as _CD - from pydantic import create_model as _cm - - patch_fields: dict[str, Any] = {} - for fname, finfo in cls.__schema_create__.model_fields.items(): - ann = finfo.annotation - patch_fields[fname] = (_Opt[ann], None) # type: ignore[valid-type] - PatchSchema = _cm( - f"{cls.__name__}PatchSchema", - __config__=_CD(from_attributes=True), - **patch_fields, - ) - validated = PatchSchema.model_validate(data) - from sqlalchemy import inspect as _sa_inspect - - nullable_cols: set[str] = { - attr.key - for attr in _sa_inspect(cls._model_class).mapper.column_attrs - if any(c.nullable for c in attr.columns) - } - update_data = { - k: v - for k, v in validated.model_dump(exclude_unset=True).items() - if k not in _AUTO_FIELDS and (v is not None or k in nullable_cols) - } - else: - validated = cls.__schema_create__.model_validate(data) - update_data = { - k: v - for k, v in validated.model_dump().items() - if k not in _AUTO_FIELDS - } - except ValidationError as exc: - return JSONResponse( - {RESPONSE_KEY_DETAIL: exc.errors()}, - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - - update_data.pop("version", None) - - async with get_async_session(engine) as session: - result = await session.execute( - update(cls._model_class) - .where( - cls._model_class.id == pk, - cls._model_class.version == client_version, - ) - .values( - **update_data, - version=client_version + 1, - updated_at=datetime.datetime.now(datetime.timezone.utc).replace( - tzinfo=None - ), - ) - ) - if result.rowcount == 0: - exists = ( - await session.execute( - sa_select(cls._model_class.id).where(cls._model_class.id == pk) - ) - ).first() - await session.rollback() - if not exists: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, - status_code=HTTPStatus.NOT_FOUND, - ) - return JSONResponse( - {RESPONSE_KEY_DETAIL: "version conflict"}, - status_code=HTTPStatus.CONFLICT, - ) - instance = ( - ( - await session.execute( - sa_select(cls._model_class).where(cls._model_class.id == pk) - ) - ) - .scalars() - .first() - ) - response_data = self._serialize_row(instance, "PUT") - return JSONResponse(response_data) - - async def _destroy_async(self, request: Request, pk: int) -> Response: - """Async mirror of destroy().""" - from lightapi.session import get_async_session + return await self._in_async_session( + lambda s: self._update(s, data, pk, partial=partial) + ) - engine = self._get_async_engine() - cls = type(self) - async with get_async_session(engine) as session: - stmt = ( - delete(cls._model_class) - .where(cls._model_class.id == pk) - .returning(cls._model_class.id) - ) - result = (await session.execute(stmt)).first() - if result is None: - return JSONResponse( - {RESPONSE_KEY_DETAIL: "not found"}, status_code=HTTPStatus.NOT_FOUND - ) - return Response(status_code=HTTPStatus.NO_CONTENT) + async def _destroy_async( + self, + request: Request, # noqa: ARG002 mirrors destroy() + pk: int, + ) -> Response: + return await self._in_async_session(lambda s: self._destroy(s, pk)) diff --git a/lightapi/route_builder.py b/lightapi/route_builder.py deleted file mode 100644 index 5f9dca9..0000000 --- a/lightapi/route_builder.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Route builder service for LightAPI. - -Extracts route creation logic from LightApi class for better SRP. -""" - -from __future__ import annotations - -import logging -from typing import Any, Callable - -from starlette.routing import Route - -logger = logging.getLogger(__name__) - - -class RouteBuilder: - """Builds Starlette routes from RestEndpoint classes. - - Separates route creation logic from the main LightApi class. - """ - - def __init__(self) -> None: - self._routes: list[Route] = [] - self._endpoint_map: dict[str, type] = {} - - @property - def routes(self) -> list[Route]: - return self._routes - - @property - def endpoint_map(self) -> dict[str, type]: - return self._endpoint_map - - def register( - self, - mapping: dict[str, type], - make_collection_handler: Callable, - make_detail_handler: Callable, - session_manager: Any = None, - ) -> None: - """Register endpoint classes as routes. - - Args: - mapping: Dict of path -> endpoint class - make_collection_handler: Factory function for collection handlers - make_detail_handler: Factory function for detail handlers - session_manager: Session manager to inject - """ - for path, cls in mapping.items(): - # Inject session manager - if session_manager is not None: - cls._session_manager = session_manager - - logger.info(f"Registering endpoint {path} -> {cls.__name__}") - logger.debug(f" SQLAlchemy metadata: {cls._meta}") - - allowed = cls._allowed_methods - - collection_route = Route( - path, - endpoint=self._make_collection_handler(cls, make_collection_handler), - methods=[m for m in allowed if m in {"GET", "POST"}], - ) - detail_route = Route( - path.rstrip("/") + "/{id:int}", - endpoint=self._make_detail_handler(cls, make_detail_handler), - methods=[m for m in allowed if m in {"GET", "PUT", "PATCH", "DELETE"}], - ) - - self._routes.extend([collection_route, detail_route]) - self._endpoint_map[path] = cls - - def _make_collection_handler( - self, - cls: type, - make_handler: Callable, - ) -> Any: - """Create a collection handler for the endpoint.""" - return make_handler(cls) - - def _make_detail_handler( - self, - cls: type, - make_handler: Callable, - ) -> Any: - """Create a detail handler for the endpoint.""" - return make_handler(cls) - - def clear(self) -> None: - """Clear all registered routes.""" - self._routes.clear() - self._endpoint_map.clear() diff --git a/lightapi/schema.py b/lightapi/schema.py index 4721a3f..75b6f99 100644 --- a/lightapi/schema.py +++ b/lightapi/schema.py @@ -2,131 +2,205 @@ import datetime import logging -from typing import Any, Callable, Optional +from collections.abc import Mapping +from decimal import Decimal +from functools import cache +from typing import Any, Final, NamedTuple, Optional +from uuid import UUID -from pydantic import ConfigDict, create_model +from pydantic import BaseModel, ConfigDict, create_model from pydantic.fields import FieldInfo +from sqlalchemy import Column, Numeric, Table, Uuid -from lightapi.constants import AUTO_FIELDS +from lightapi.config import Serializer +from lightapi.constants import AUTO_FIELDS, HttpVerb from lightapi.exceptions import ConfigurationError, SerializationError logger = logging.getLogger(__name__) -_AUTO_FIELDS = AUTO_FIELDS -# Type registry for extensible column type mapping (OCP) -_TYPE_ANNOTATION_REGISTRY: dict[type, Callable[[Any], type]] = {} +class FieldProjection(NamedTuple): + """Which fields a response shows: one list for every verb, or read and write lists. -class SchemaHelper: - """Helper class for schema operations. - - Provides a unified interface for serialization, field projection, - and row-to-dict conversion. + Lives in ``RestEndpoint._meta["serializer_normalised"]`` as the (fields, read, + write) tuple it has always been. """ - @staticmethod - def normalise_serializer( - serializer: object, - ) -> tuple[list[str] | None, list[str] | None, list[str] | None]: - """Return (fields, read, write) from any Serializer form.""" - return normalise_serializer(serializer) - - @staticmethod - def resolve_fields(cls: type, method: str) -> list[str] | None: - """Return the field list to project for the given HTTP method.""" - return resolve_fields(cls, method) - - @staticmethod - def row_to_dict(row: Any) -> dict[str, Any]: - """Convert a SQLAlchemy row or ORM instance to a plain dict.""" - return _row_to_dict(row) + fields: list[str] | None + read: list[str] | None + write: list[str] | None - @staticmethod - def apply_fields(d: dict[str, Any], fields: list[str] | None) -> dict[str, Any]: - """Project a dict to only the requested field names.""" - return _apply_fields(d, fields) - - -def register_column_type(db_type: type, annotation_fn: Callable[[Any], type]) -> None: - """Register a column type to annotation mapping for extensibility. - - Args: - db_type: SQLAlchemy column type class - annotation_fn: Function that takes column and returns Pydantic annotation - """ - _TYPE_ANNOTATION_REGISTRY[db_type] = annotation_fn + @classmethod + def from_serializer(cls, serializer: object) -> FieldProjection: + """The projection of any Serializer form; no serializer projects nothing. - -def normalise_serializer( - serializer: object, -) -> tuple[list[str] | None, list[str] | None, list[str] | None]: - """Return (fields, read, write) from any Serializer form. - - Accepts a Serializer instance (forms 1-3) or a Serializer subclass (form 4). - Raises ConfigurationError for non-Serializer types. - """ - from lightapi.config import Serializer - - if serializer is None: - return None, None, None - - if isinstance(serializer, type): - if not issubclass(serializer, Serializer): + Accepts a Serializer instance (forms 1-3) or a Serializer subclass (form 4). + Raises ConfigurationError for non-Serializer types. + """ + if serializer is None: + return cls(None, None, None) + + if isinstance(serializer, type): + if not issubclass(serializer, Serializer): + raise ConfigurationError( + f"Meta.serializer must be a Serializer subclass, " + f"got '{serializer.__name__}'." + ) + instance = serializer() + return cls(instance.fields, instance.read, instance.write) + + if not isinstance(serializer, Serializer): raise ConfigurationError( - f"Meta.serializer must be a Serializer subclass, " - f"got '{serializer.__name__}'." + f"Meta.serializer must be a Serializer instance or subclass, " + f"got '{type(serializer).__name__}'." ) - instance = serializer() - return instance.fields, instance.read, instance.write + return cls(serializer.fields, serializer.read, serializer.write) - if not isinstance(serializer, Serializer): - raise ConfigurationError( - f"Meta.serializer must be a Serializer instance or subclass, " - f"got '{type(serializer).__name__}'." - ) - return serializer.fields, serializer.read, serializer.write + def for_method(self, method: str) -> list[str] | None: + """The field list to project for an HTTP method; None means every field.""" + if self.fields: + return self.fields + if method.upper() == HttpVerb.GET: + return self.read + return self.write + @staticmethod + def apply(d: dict[str, Any], fields: list[str] | None) -> dict[str, Any]: + """Project a dict to only the requested field names. None → passthrough.""" + if fields is None: + return d + return {k: v for k, v in d.items() if k in fields} + + +# Python types a reflected column is validated as; any other type stays ``Any``. +_REFLECTED_PYTHON_TYPES = frozenset( + { + int, + str, + float, + bool, + Decimal, + UUID, + datetime.datetime, + datetime.date, + datetime.time, + } +) + + +# Every read schema carries the auto-managed columns, after the endpoint's own fields. +_AUTO_READ_FIELDS: Final[dict[str, Any]] = { + "id": (int | None, None), + "created_at": (datetime.datetime | None, None), + "updated_at": (datetime.datetime | None, None), + "version": (int | None, None), +} -def resolve_fields(cls: type, method: str) -> list[str] | None: - """Return the field list to project for the given HTTP method.""" - fields, read, write = cls._meta.get("serializer_normalised", (None, None, None)) - if fields: - return fields - if method.upper() == "GET": - return read - return write +class SchemaFactory: + """Builds Pydantic validation models from a RestEndpoint class, and turns rows + into the dicts those models validate.""" -def _row_to_dict(row: Any) -> dict[str, Any]: - """Convert a SQLAlchemy row or ORM instance to a plain dict. + @staticmethod + def row_to_dict(row: object) -> dict[str, Any]: + """Convert a SQLAlchemy row or ORM instance to a plain dict. + + Handles four cases: + - Plain dict → passthrough + - SQLAlchemy ORM mapped instance (has __mapper__) → use descriptor access + - SQLAlchemy Row/LegacyRow (has _mapping) → dict(_mapping) + - Arbitrary object with __dict__ → filter private attrs + """ + if isinstance(row, dict): + return row + # ORM-mapped instance: use descriptor access to trigger lazy loads + if hasattr(row, "__mapper__"): + return { + col.key: getattr(row, col.key) for col in row.__mapper__.column_attrs + } + if hasattr(row, "_mapping"): + return dict(row._mapping) # noqa: SLF001 SQLAlchemy's public Row mapping + if hasattr(row, "__dict__"): + return {k: v for k, v in row.__dict__.items() if not k.startswith("_")} + raise SerializationError(f"Cannot convert {type(row)} to dict.") - Handles four cases: - - Plain dict → passthrough - - SQLAlchemy ORM mapped instance (has __mapper__) → use descriptor access - - SQLAlchemy Row/LegacyRow (has _mapping) → dict(_mapping) - - Arbitrary object with __dict__ → filter private attrs - """ - if isinstance(row, dict): - return row - # ORM-mapped instance: use descriptor access to trigger lazy loads - if hasattr(row, "__mapper__"): - return {col.key: getattr(row, col.key) for col in row.__mapper__.column_attrs} - if hasattr(row, "_mapping"): - return dict(row._mapping) - if hasattr(row, "__dict__"): - return {k: v for k, v in row.__dict__.items() if not k.startswith("_")} - raise SerializationError(f"Cannot convert {type(row)} to dict.") + @staticmethod + @cache + def patch_schema(schema_create: type[BaseModel]) -> type[BaseModel]: + """``schema_create`` with every field optional, so PATCH can send any subset. + Cached per create schema: endpoints are a fixed set of classes, and building + a pydantic model on every PATCH request was measurable work for nothing. + """ + # Optional[...] because `| None` fails on what gets here: pydantic may hand + # back None, and a user module under `from __future__ import annotations` + # declares its fields as strings. The FieldInfo travels with the field so + # a PATCH meets the same constraints as a PUT; only the default changes. + optional_fields: dict[str, Any] = { + name: ( + Optional[field.annotation], # noqa: UP045 + FieldInfo.merge_field_infos(field, default=None), + ) + for name, field in schema_create.model_fields.items() + } + return create_model( + schema_create.__name__.replace("CreateSchema", "PatchSchema"), + __config__=ConfigDict(from_attributes=True), + **optional_fields, + ) -def _apply_fields(d: dict[str, Any], fields: list[str] | None) -> dict[str, Any]: - """Project a dict to only the requested field names. None → passthrough.""" - if fields is None: - return d - return {k: v for k, v in d.items() if k in fields} + @staticmethod + def _reflected_annotation( + column: Column[Any], + ) -> Any: # noqa: ANN401 a type, or typing.Any itself + """Annotation for a reflected column, from SQLAlchemy's own type mapping.""" + # Two answers of python_type differ from what released versions validated, + # and clients see the difference (a Decimal is a JSON string, a float a JSON + # number): Float, Double and REAL are Numeric subclasses and have always + # been Decimal here, and a Uuid column is a UUID whatever its as_uuid flag. + if isinstance(column.type, Numeric): + return Decimal + if isinstance(column.type, Uuid): + return UUID + try: + python_type = column.type.python_type + except NotImplementedError: + python_type = None + if python_type in _REFLECTED_PYTHON_TYPES: + return python_type + logger.warning( + "Unknown SQLAlchemy type %s for column %s; using Any", + type(column.type).__name__, + column.name, + ) + return Any + @staticmethod + def _schema_pair( + endpoint_name: str, create_fields: dict[str, Any], read_fields: dict[str, Any] + ) -> tuple[type, type]: + """(create schema, read schema) for an endpoint. -class SchemaFactory: - """Builds Pydantic validation models from a RestEndpoint class.""" + create: input validation for POST/PUT/PATCH. + read: response serialization; ``extra="allow"`` lets join labels through. + """ + missing_auto = { + k: v for k, v in _AUTO_READ_FIELDS.items() if k not in read_fields + } + schema_create = create_model( + f"{endpoint_name}CreateSchema", + __config__=ConfigDict(from_attributes=True), + **create_fields, + ) + schema_read = create_model( + f"{endpoint_name}ReadSchema", + __config__=ConfigDict(from_attributes=True, extra="allow"), + **read_fields, + **missing_auto, + ) + schema_create.model_rebuild() + schema_read.model_rebuild() + return schema_create, schema_read @staticmethod def build(cls: type) -> tuple[type, type]: @@ -151,232 +225,54 @@ def build(cls: type) -> tuple[type, type]: } ) - field_infos: dict[str, FieldInfo] = {} - for name in user_annotations: - val = cls.__dict__.get(name) or getattr(cls, name, None) - if isinstance(val, FieldInfo): - field_infos[name] = val - create_fields: dict[str, Any] = {} read_fields: dict[str, Any] = {} - - from typing import Optional - for name, annotation in user_annotations.items(): - if name in _AUTO_FIELDS: + if name in AUTO_FIELDS: continue - fi = field_infos.get(name) - extra = (fi.json_schema_extra or {}) if fi else {} - if extra.get("exclude"): + value = cls.__dict__.get(name) or getattr(cls, name, None) + field_info = value if isinstance(value, FieldInfo) else None + extra = field_info.json_schema_extra if field_info is not None else None + if isinstance(extra, dict) and extra.get("exclude"): continue - if fi is not None: - # create: FieldInfo with constraints for INPUT validation - create_fields[name] = (annotation, fi) - # read: Optional[T] so serializer can project out any field - read_fields[name] = (Optional[annotation], None) # type: ignore[valid-type] - else: - create_fields[name] = (annotation, ...) - read_fields[name] = (Optional[annotation], None) # type: ignore[valid-type] + # create keeps the Field() constraints; read is Optional so a + # serializer can project any field out. + default = field_info if field_info is not None else ... + create_fields[name] = (annotation, default) + # A string annotation (future annotations) has no `| None`. + read_fields[name] = (Optional[annotation], None) # noqa: UP045 - import datetime - from typing import Optional - - read_fields["id"] = (Optional[int], None) - read_fields["created_at"] = (Optional[datetime.datetime], None) - read_fields["updated_at"] = (Optional[datetime.datetime], None) - read_fields["version"] = (Optional[int], None) - - from pydantic import ConfigDict - - schema_create = create_model( - f"{cls.__name__}CreateSchema", - __config__=ConfigDict(from_attributes=True), - **create_fields, - ) - schema_read = create_model( - f"{cls.__name__}ReadSchema", - __config__=ConfigDict(from_attributes=True, extra="allow"), - **read_fields, - ) - schema_create.model_rebuild() - schema_read.model_rebuild() - return schema_create, schema_read + return SchemaFactory._schema_pair(cls.__name__, create_fields, read_fields) @staticmethod - def build_from_reflected_table(cls: type, table: Any) -> tuple[type, type]: + def build_from_reflected_table( + cls: type, table: Table, declared: Mapping[str, FieldInfo] | None = None + ) -> tuple[type, type]: """Build __schema_create__ and __schema_read__ from reflected table columns. Maps SQLAlchemy column types to Pydantic-compatible annotations. Excludes id, created_at, updated_at, version from create schema. Includes all columns in read schema. - Uses Optional[T] when column.nullable is True. + A nullable column is optional on create (None when absent); a column the + endpoint declared itself (partial reflection, ``declared``) keeps its + ``Field()``: its default and its constraints. """ - from decimal import Decimal - from uuid import UUID - - from sqlalchemy import ( - Boolean, - Date, - DateTime, - Float, - Integer, - Numeric, - SmallInteger, - String, - Text, - Time, - ) - from sqlalchemy.types import BigInteger - - try: - from sqlalchemy import Uuid as SAUuid - except ImportError: - SAUuid = None # type: ignore[assignment,misc] - try: - from sqlalchemy.dialects.postgresql import UUID as PG_UUID - except ImportError: - PG_UUID = None # type: ignore[assignment,misc] - - def _col_type_to_annotation(col: Any) -> Any | None: - """Map SQLAlchemy column type to Pydantic annotation. None if unknown.""" - col_type = type(col.type) - if isinstance(col.type, (Integer, BigInteger, SmallInteger)): - return int - if col_type in (String, Text) or issubclass(col_type, String): - return str - if col_type == Numeric or ( - hasattr(Numeric, "__mro__") and Numeric in col_type.__mro__ - ): - return Decimal - if col_type == Float or ( - hasattr(Float, "__mro__") and Float in getattr(col_type, "__mro__", ()) - ): - return float - if col_type == Boolean: - return bool - if col_type == Time or ( - hasattr(Time, "__mro__") and Time in getattr(col_type, "__mro__", ()) - ): - return datetime.time - if col_type in (DateTime,) or ( - hasattr(DateTime, "__mro__") - and DateTime in getattr(col_type, "__mro__", ()) - ): - return datetime.datetime - if col_type == Date or ( - hasattr(Date, "__mro__") and Date in getattr(col_type, "__mro__", ()) - ): - return datetime.date - if SAUuid is not None and col_type == SAUuid: - return UUID - if PG_UUID is not None and col_type == PG_UUID: - return UUID - type_name = ( - getattr(col.type, "__visit_name__", "") or col_type.__name__ or "" - ).lower() - if type_name in ("integer", "big_integer", "small_integer", "int"): - return int - if type_name in ( - "string", - "varchar", - "char", - "text", - "unicode", - "unicode_text", - ): - return str - if type_name in ("numeric", "decimal"): - return Decimal - if type_name in ("float", "double", "real"): - return float - if type_name == "boolean": - return bool - if type_name in ("datetime", "timestamp"): - return datetime.datetime - if type_name == "date": - return datetime.date - if type_name == "time": - return datetime.time - if type_name == "uuid": - return UUID - logger.warning( - "Unknown SQLAlchemy type %s for column %s; using Any", - col_type.__name__, - col.name, - ) - return None - + declared = declared or {} create_fields: dict[str, Any] = {} read_fields: dict[str, Any] = {} - for col in table.c: - name = col.key - annotation = _col_type_to_annotation(col) - if annotation is None: - annotation = Any - use_optional = col.nullable - ann = Optional[annotation] if use_optional else annotation # type: ignore[valid-type] - - if name not in _AUTO_FIELDS: - create_fields[name] = (ann, ...) - - read_fields[name] = (Optional[annotation], None) # type: ignore[valid-type] - - if "id" not in read_fields: - read_fields["id"] = (Optional[int], None) - if "created_at" not in read_fields: - read_fields["created_at"] = (Optional[datetime.datetime], None) - if "updated_at" not in read_fields: - read_fields["updated_at"] = (Optional[datetime.datetime], None) - if "version" not in read_fields: - read_fields["version"] = (Optional[int], None) - - schema_create = create_model( - f"{cls.__name__}CreateSchema", - __config__=ConfigDict(from_attributes=True), - **create_fields, - ) - schema_read = create_model( - f"{cls.__name__}ReadSchema", - __config__=ConfigDict(from_attributes=True, extra="allow"), - **read_fields, - ) - schema_create.model_rebuild() - schema_read.model_rebuild() - return schema_create, schema_read - - -def _strip_lightapi_kwargs(fi: FieldInfo) -> FieldInfo: - """Copy of FieldInfo with LightAPI-only keys removed from json_schema_extra.""" - from pydantic import Field as pydantic_Field - from pydantic_core import PydanticUndefined - - from lightapi.fields import _LIGHTAPI_KWARGS - - extra = fi.json_schema_extra or {} - clean_extra = {k: v for k, v in extra.items() if k not in _LIGHTAPI_KWARGS} - - kwargs: dict[str, Any] = {} - if fi.default is not PydanticUndefined: - kwargs["default"] = fi.default - if fi.default_factory is not None: - kwargs["default_factory"] = fi.default_factory - for attr in ( - "title", - "description", - "gt", - "ge", - "lt", - "le", - "min_length", - "max_length", - "pattern", - ): - val = getattr(fi, attr, None) - if val is not None: - kwargs[attr] = val - if clean_extra: - kwargs["json_schema_extra"] = clean_extra - - return pydantic_Field(**kwargs) # type: ignore[return-value] + annotation = SchemaFactory._reflected_annotation(col) + # Optional[...]: `Any | None` is a typing form `| None` cannot build. + nullable: Any = Optional[annotation] # noqa: UP045 + create_annotation = nullable if col.nullable else annotation + if col.key in declared: + create_fields[col.key] = (create_annotation, declared[col.key]) + elif col.key not in AUTO_FIELDS: + create_fields[col.key] = ( + create_annotation, + None if col.nullable else ..., + ) + read_fields[col.key] = (nullable, None) + + return SchemaFactory._schema_pair(cls.__name__, create_fields, read_fields) diff --git a/lightapi/server.py b/lightapi/server.py index 830e471..7dd273c 100644 --- a/lightapi/server.py +++ b/lightapi/server.py @@ -24,7 +24,7 @@ class ServerCommand: serving to :meth:`LightApi.run`. """ - DEFAULT_HOST = "0.0.0.0" + DEFAULT_HOST = "0.0.0.0" # noqa: S104 the serve command's documented default DEFAULT_PORT = 8000 def __init__(self, config_path: str, host: str, port: int) -> None: diff --git a/lightapi/session.py b/lightapi/session.py index de57ba8..d99ccea 100644 --- a/lightapi/session.py +++ b/lightapi/session.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import AsyncGenerator, Generator from contextlib import asynccontextmanager, contextmanager -from typing import TYPE_CHECKING, AsyncGenerator, Generator +from typing import TYPE_CHECKING from sqlalchemy.orm import Session @@ -26,7 +27,9 @@ def get_sync_session(engine: Engine) -> Generator[Session, None, None]: @asynccontextmanager async def get_async_session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: - """Yield AsyncSession (expire_on_commit=False); commit on exit, rollback on exception.""" + """Yield an AsyncSession (expire_on_commit=False); commit on exit, rollback on + exception.""" + # sqlalchemy[asyncio] is an optional extra: only an app with an AsyncEngine has it. from sqlalchemy.ext.asyncio import AsyncSession async with AsyncSession(engine, expire_on_commit=False) as session: diff --git a/lightapi/session_manager.py b/lightapi/session_manager.py index a42ce04..17c5cc1 100644 --- a/lightapi/session_manager.py +++ b/lightapi/session_manager.py @@ -1,110 +1,96 @@ -"""Session management abstraction for LightAPI. - -Replaces the global registry pattern with proper dependency injection. -Provides both sync and async session management with proper cleanup. -""" +"""Engine, registry and metadata holder for one LightApi app.""" from __future__ import annotations -import contextlib +import logging import threading -from typing import Any, Protocol, Union, runtime_checkable - -from sqlalchemy import MetaData -from sqlalchemy.orm import Session, registry - -# Thread-local storage for test isolation -_thread_local = threading.local() - - -def _get_test_metadata(): - """Get or create test-specific metadata.""" - if not hasattr(_thread_local, "metadata"): - _thread_local.metadata = MetaData() - return _thread_local.metadata - +from collections.abc import Callable +from typing import Any, TypeVar -def _get_test_registry(): - """Get or create test-specific registry.""" - if not hasattr(_thread_local, "registry"): - _thread_local.registry = registry(metadata=_get_test_metadata()) - return _thread_local.registry +from sqlalchemy import Engine, MetaData +from sqlalchemy.orm import registry +from sqlalchemy.pool import SingletonThreadPool, StaticPool +from starlette.concurrency import run_in_threadpool +logger = logging.getLogger(__name__) -def get_unique_table_name(base_name: str) -> str: - """Get a unique table name for test isolation.""" - import logging +T = TypeVar("T") - logger = logging.getLogger(__name__) +# In-memory SQLite runs on one of these. SingletonThreadPool gives every thread +# its own empty database, and StaticPool shares a single connection that is not +# safe to use from two threads at once. +_SINGLE_CONNECTION_POOLS = (SingletonThreadPool, StaticPool) - if not hasattr(_thread_local, "table_counter"): - _thread_local.table_counter = {} - if base_name not in _thread_local.table_counter: - _thread_local.table_counter[base_name] = 0 +class _ThreadState(threading.local): + """Metadata, registry and table-name counters of one thread (test isolation). - _thread_local.table_counter[base_name] += 1 - counter = _thread_local.table_counter[base_name] - - # For the first table, use the base name, for subsequent ones add counter - if counter == 1: - result = base_name - else: - result = f"{base_name}_{counter}" - - logger.debug(f"get_unique_table_name: {base_name} -> {result} (counter: {counter})") - return result + ``threading.local`` runs ``__init__`` again on first use from each thread. + """ + def __init__(self) -> None: + self.metadata = MetaData() + self.registry = registry(metadata=self.metadata) + self.table_counter: dict[str, int] = {} -def clear_test_registries(): - """Clear test-specific registries and metadata.""" - if hasattr(_thread_local, "metadata"): - delattr(_thread_local, "metadata") - if hasattr(_thread_local, "registry"): - _thread_local.registry.dispose() - delattr(_thread_local, "registry") - if hasattr(_thread_local, "table_counter"): - delattr(_thread_local, "table_counter") +class _GlobalState: + """Process-wide SQLAlchemy metadata/registry, plus a thread-local copy. -# Global shared metadata for all endpoints (backward compatibility) -_GLOBAL_METADATA = MetaData() -_GLOBAL_REGISTRY = registry(metadata=_GLOBAL_METADATA) + Every non-isolated SessionManager shares the one `metadata`/`registry` + pair below. `use_test_isolation=True` gets a separate pair per thread + instead (plus per-thread table-name counters), so tests running in + parallel don't map two different classes onto the same table name. + """ + def __init__(self) -> None: + self._local = _ThreadState() + self.reset() -class EngineProtocol(Protocol): - """Protocol for SQLAlchemy engines (sync or async).""" + def reset(self) -> None: + """Start over with fresh global metadata/registry. - def dispose(self) -> None: ... + Lets a test that redefines an endpoint class under a name already + used by a previous test (see tests/test_examples_e2e.py) map it + without inheriting columns from that earlier mapping. + """ + self.metadata = MetaData() + self.registry = registry(metadata=self.metadata) @property - def url(self) -> Any: ... - - -@runtime_checkable -class AsyncEngineProtocol(Protocol): - """Protocol for SQLAlchemy async engines.""" - - def dispose(self) -> None: ... + def test_metadata(self) -> MetaData: + return self._local.metadata @property - def url(self) -> Any: ... + def test_registry(self) -> registry: + return self._local.registry - @property - def sync_engine(self) -> Any: ... + def unique_table_name(self, base_name: str) -> str: + """A table name unique to this thread, for parallel test isolation.""" + counter = self._local.table_counter.get(base_name, 0) + 1 + self._local.table_counter[base_name] = counter + result = base_name if counter == 1 else f"{base_name}_{counter}" + logger.debug( + "unique_table_name: %s -> %s (counter: %s)", base_name, result, counter + ) + return result -EngineType = Union[EngineProtocol, AsyncEngineProtocol, Any] +_state = _GlobalState() class SessionManager: - """Manages SQLAlchemy sessions and registry for LightAPI. + """Holds the engine plus the registry and metadata endpoints are mapped into. - Replaces the global registry pattern with instance-based management. + Sessions are opened by the CRUD code itself, not here. Supports both sync and async engines. """ - def __init__(self, engine: EngineType, use_test_isolation: bool = False) -> None: + def __init__( + self, + engine: Any, # noqa: ANN401 an Engine or an AsyncEngine; which is a runtime fact + use_test_isolation: bool = False, + ) -> None: """Initialize with an engine (sync or async). Args: @@ -117,17 +103,36 @@ def __init__(self, engine: EngineType, use_test_isolation: bool = False) -> None # Use test-specific or global metadata based on configuration if use_test_isolation: - self._metadata = _get_test_metadata() - self._registry = _get_test_registry() + self._metadata = _state.test_metadata + self._registry = _state.test_registry else: - self._metadata = _GLOBAL_METADATA - self._registry = _GLOBAL_REGISTRY + self._metadata = _state.metadata + self._registry = _state.registry @property - def engine(self) -> EngineType: + def engine(self) -> Any: # noqa: ANN401 an Engine or an AsyncEngine (see is_async) """Get the engine.""" return self._engine + @property + def sync_engine(self) -> Engine: + """The engine sync code can use: an AsyncEngine's underlying sync one.""" + engine: Engine = self._engine.sync_engine if self._is_async else self._engine + return engine + + async def run_blocking( + self, + fn: Callable[..., T], + *args: Any, # noqa: ANN401 passed through to fn + ) -> T: + """Run a blocking database call in a worker thread so the event loop stays free. + + An engine on a single-connection pool runs the call in place instead. + """ + if isinstance(self.sync_engine.pool, _SINGLE_CONNECTION_POOLS): + return fn(*args) + return await run_in_threadpool(fn, *args) + @property def metadata(self) -> MetaData: """Get the metadata.""" @@ -140,88 +145,15 @@ def registry(self) -> registry: @property def is_async(self) -> bool: - """Check if engine is async.""" return self._is_async - def get_registry_and_metadata(self) -> tuple[registry, MetaData]: - """Get registry and metadata (compatibility with old API).""" - return self._registry, self._metadata - - @contextlib.contextmanager - def session(self): - """Get a sync session context manager. - - Yields: - SQLAlchemy Session - """ - if self._is_async: - # For async engines, use the sync engine - from sqlalchemy.ext.asyncio import AsyncEngine - - async_engine = self._engine - if isinstance(async_engine, AsyncEngine): - engine = async_engine.sync_engine - else: - raise TypeError(f"Expected AsyncEngine, got {type(async_engine)}") - else: - engine = self._engine - - with Session(engine) as session: - yield session - - @contextlib.asynccontextmanager - async def async_session(self): - """Get an async session context manager. + def table_name_for(self, base_name: str) -> str: + """The table name to use; test isolation gives every mapping a fresh one.""" + if self._use_test_isolation: + return _state.unique_table_name(base_name) + return base_name - Yields: - SQLAlchemy AsyncSession - """ + def create_tables_now(self) -> None: + """Create missing tables on a sync engine; async engines wait for the loop.""" if not self._is_async: - raise TypeError("Cannot create async session from sync engine") - - from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession - - if not isinstance(self._engine, AsyncEngine): - raise TypeError(f"Expected AsyncEngine, got {type(self._engine)}") - - async with AsyncSession(self._engine) as session: - yield session - - def dispose(self) -> None: - """Dispose the engine.""" - self._engine.dispose() - - -class LoginValidator: - """Manages login validation functions.""" - - def __init__(self, validator: Any = None) -> None: - """Initialize with optional validator function. - - Args: - validator: Function that accepts (username, password) and returns - user payload dict or None - """ - self._validator = validator - - def set_validator(self, validator: Any) -> None: - """Set the validator function.""" - self._validator = validator - - def get_validator(self) -> Any: - """Get the validator function.""" - return self._validator - - def __call__(self, username: str, password: str) -> dict[str, Any] | None: - """Validate credentials using the registered validator. - - Args: - username: Username - password: Password - - Returns: - User payload dict or None if validation fails - """ - if self._validator is None: - return None - return self._validator(username, password) + self._metadata.create_all(self._engine) diff --git a/lightapi/swagger.py b/lightapi/swagger.py deleted file mode 100644 index 1229583..0000000 --- a/lightapi/swagger.py +++ /dev/null @@ -1,323 +0,0 @@ -import inspect -import typing # noqa: F401 -from typing import Any, Dict, Type - -from sqlalchemy import Column -from starlette.responses import HTMLResponse, JSONResponse - -from .rest import RestEndpoint - - -class SwaggerGenerator: - """ - Generates OpenAPI documentation from LightAPI endpoint classes. - - This class analyzes RestEndpoint classes to extract information about - their schemas, HTTP methods, validation, and other metadata to build - a complete OpenAPI specification document. - - Attributes: - title (str): The title of the API documentation. - version (str): The API version. - description (str): A description of the API. - paths (dict): Endpoint paths and their operations. - components (dict): Schema definitions and security schemes. - """ - - def __init__( - self, - title: str = "LightAPI Documentation", - version: str = "1.0.0", - description: str = "API documentation", - ): - """ - Initialize a new SwaggerGenerator. - - Args: - title: The title of the API documentation. - version: The API version. - description: A description of the API. - """ - self.title = title - self.version = version - self.description = description - self.paths = {} - self.components = { - "schemas": {}, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - } - }, - } - - def register_endpoint(self, path: str, endpoint_class: Type[RestEndpoint]): - """ - Register an endpoint class for OpenAPI documentation. - - Analyzes the endpoint class to extract HTTP methods, schemas, - and other metadata to include in the OpenAPI documentation. - - Args: - path: The URL path where the endpoint is mounted. - endpoint_class: The RestEndpoint class to document. - """ - methods = ( - endpoint_class.Configuration.http_method_names - if hasattr(endpoint_class, "Configuration") - and hasattr(endpoint_class.Configuration, "http_method_names") - else ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] - ) - - model_name = endpoint_class.__name__ - self.components["schemas"][model_name] = self._generate_schema(endpoint_class) - - path_operations = {} - for method in methods: - method_lower = method.lower() - if hasattr(endpoint_class, method_lower): - operation = self._generate_operation( - endpoint_class, method_lower, model_name - ) - path_operations[method_lower] = operation - - self.paths[path] = path_operations - - def _generate_schema(self, endpoint_class: Type[RestEndpoint]) -> Dict[str, Any]: - """ - Generate an OpenAPI schema from an endpoint class. - - Extracts information about the model fields, their types, - and validation requirements to create an OpenAPI schema definition. - - Args: - endpoint_class: The RestEndpoint class to analyze. - - Returns: - A dict containing the OpenAPI schema definition. - """ - properties = {} - required = [] - - if ( - hasattr(endpoint_class, "__table__") - and endpoint_class.__table__ is not None - ): - for column in endpoint_class.__table__.columns: - column_type = self._map_sql_type_to_openapi(column.type) - properties[column.name] = column_type - - if ( - not column.nullable - and not column.default - and not column.server_default - ): - required.append(column.name) - else: - for attr_name in dir(endpoint_class): - if attr_name.startswith("_") or callable( - getattr(endpoint_class, attr_name) - ): - continue - - attr = getattr(endpoint_class, attr_name) - if isinstance(attr, Column): - properties[attr_name] = self._map_sql_type_to_openapi(attr.type) - - if hasattr(attr, "nullable") and not attr.nullable: - required.append(attr_name) - - description = "" - if endpoint_class.__doc__: - description = inspect.getdoc(endpoint_class) - - return { - "type": "object", - "description": description, - "properties": properties, - "required": required, - } - - def _map_sql_type_to_openapi(self, sql_type) -> Dict[str, Any]: - """ - Map SQLAlchemy column types to OpenAPI data types. - - Args: - sql_type: SQLAlchemy type object. - - Returns: - A dict containing the OpenAPI type definition. - """ - type_map = { - "INTEGER": {"type": "integer"}, - "BIGINT": {"type": "integer", "format": "int64"}, - "SMALLINT": {"type": "integer"}, - "VARCHAR": {"type": "string"}, - "TEXT": {"type": "string"}, - "BOOLEAN": {"type": "boolean"}, - "FLOAT": {"type": "number", "format": "float"}, - "NUMERIC": {"type": "number"}, - "DATETIME": {"type": "string", "format": "date-time"}, - "DATE": {"type": "string", "format": "date"}, - "TIME": {"type": "string", "format": "time"}, - } - - type_name = sql_type.__class__.__name__.upper() - if type_name in type_map: - return type_map[type_name] - return {"type": "string"} - - def _generate_operation( - self, endpoint_class: Type[RestEndpoint], method: str, model_name: str - ) -> Dict[str, Any]: - """ - Generate an OpenAPI operation object for an endpoint method. - - Args: - endpoint_class: The RestEndpoint class. - method: The HTTP method name (lowercase). - model_name: The model name for reference. - - Returns: - A dict containing the OpenAPI operation definition. - """ - method_handler = getattr(endpoint_class, method, None) - description = "" - if method_handler and method_handler.__doc__: - description = inspect.getdoc(method_handler) - - operation = { - "tags": [model_name], - "summary": f"{method.upper()} {model_name}", - "description": description, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": {"$ref": f"#/components/schemas/{model_name}"} - } - }, - }, - "400": {"description": "Bad request"}, - "401": {"description": "Unauthorized"}, - "403": {"description": "Forbidden"}, - "404": {"description": "Not found"}, - }, - } - - if hasattr(endpoint_class, "Configuration") and hasattr( - endpoint_class.Configuration, "authentication_class" - ): - operation["security"] = [{"bearerAuth": []}] - - if method in ["post", "put", "patch"]: - operation["requestBody"] = { - "content": { - "application/json": { - "schema": {"$ref": f"#/components/schemas/{model_name}"} - } - } - } - - return operation - - def generate_openapi_spec(self) -> Dict[str, Any]: - """ - Generate the complete OpenAPI specification document. - - Returns: - A dict containing the full OpenAPI specification. - """ - return { - "openapi": "3.0.0", - "info": { - "title": self.title, - "version": self.version, - "description": self.description, - }, - "paths": self.paths, - "components": self.components, - } - - def get_swagger_ui(self) -> HTMLResponse: - """ - Generate the Swagger UI HTML page for interactive API documentation. - - Returns: - An HTMLResponse containing the Swagger UI interface. - """ - return HTMLResponse( - """ - - - - - LightAPI - Swagger UI - - - - -
- - - - - """ - ) - - def get_openapi_json(self) -> JSONResponse: - """ - Generate the OpenAPI specification as a JSON response. - - Returns: - A JSONResponse containing the OpenAPI specification. - """ - return JSONResponse(self.generate_openapi_spec()) - - -def swagger_ui_route(request): - """ - Handle requests for the Swagger UI page. - - Args: - request: The incoming HTTP request. - - Returns: - The Swagger UI HTML response. - """ - generator = request.app.state.swagger_generator - return generator.get_swagger_ui() - - -def openapi_json_route(request): - """ - Handle requests for the OpenAPI JSON specification. - - Args: - request: The incoming HTTP request. - - Returns: - The OpenAPI specification as JSON. - """ - generator = request.app.state.swagger_generator - return generator.get_openapi_json() diff --git a/lightapi/table_mapping.py b/lightapi/table_mapping.py index efc924b..612a245 100644 --- a/lightapi/table_mapping.py +++ b/lightapi/table_mapping.py @@ -1,239 +1,302 @@ -from typing import Any +"""Where an endpoint's table comes from: its declared fields or an existing table.""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import datetime +import types +from abc import ABC, abstractmethod +from decimal import Decimal +from typing import ( + TYPE_CHECKING, + Any, + Final, + Union, + get_args, + get_origin, + get_type_hints, +) +from uuid import UUID + +from pydantic.fields import FieldInfo +from sqlalchemy import ( + Boolean, + Column, + Connection, + DateTime, + Engine, + Float, + ForeignKey, + Integer, + MetaData, + Numeric, + String, + Table, + Uuid, +) +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.pool import StaticPool + +from lightapi.constants import AUTO_FIELDS, TableReflection +from lightapi.exceptions import ConfigurationError +from lightapi.repository import Repository +from lightapi.schema import SchemaFactory + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + + from lightapi.rest import RestEndpoint + from lightapi.session_manager import SessionManager + +_TYPE_MAP: Final[dict[Any, Any]] = { + str: String, + int: Integer, + float: Float, + bool: Boolean, + datetime.datetime: DateTime, + Decimal: Numeric, + UUID: Uuid, +} + + +class TableSource(ABC): + """Builds the endpoint's table and maps the endpoint class onto it.""" + + @abstractmethod + def map( + self, endpoint_cls: type[RestEndpoint], session_manager: SessionManager + ) -> None: ... -from sqlalchemy import Column, MetaData, Table -from sqlalchemy.orm import registry - - -class TableNameResolver: @staticmethod - def resolve(meta_obj: Any, name: str, session_manager: Any | None = None) -> str: - base_name = getattr(meta_obj, "table", None) or f"{name.lower()}s" - if session_manager is not None and getattr( - session_manager, "_use_test_isolation", False - ): - from lightapi.session_manager import get_unique_table_name - - return get_unique_table_name(base_name) - return base_name - + def _table_name( + endpoint_cls: type[RestEndpoint], session_manager: SessionManager + ) -> str: + meta = endpoint_cls._meta # noqa: SLF001 the endpoint's parsed Meta + base_name = meta.get("table") or f"{endpoint_cls.__name__.lower()}s" + return session_manager.table_name_for(base_name) -class FieldInfoStripper: @staticmethod - def strip(cls: type, columns: list[Column]) -> None: - from pydantic.fields import FieldInfo - - for col in columns: - existing = cls.__dict__.get(col.name) + def _strip_field_infos( + endpoint_cls: type[RestEndpoint], columns: list[Column[Any]] + ) -> None: + for column in columns: + existing = endpoint_cls.__dict__.get(column.name) # Remove FieldInfo objects AND bare Ellipsis sentinels. Both block # SQLAlchemy's instrumentation from replacing the class attribute # with an InstrumentedAttribute, which silently drops the column # from the mapper. if isinstance(existing, FieldInfo) or existing is ...: - try: - delattr(cls, col.name) - except AttributeError: - pass - - -class ImperativeTableBuilder: - def build( - self, - cls: type, - name: str, - columns: list[Column], - meta_obj: Any, - session_manager: Any | None = None, - ) -> Table: - registry_obj, metadata = self._get_registry_and_metadata(session_manager) - table_name = TableNameResolver.resolve(meta_obj, name, session_manager) + delattr(endpoint_cls, column.name) + - FieldInfoStripper.strip(cls, columns) +class DeclaredTable(TableSource): + """A table generated from the endpoint's annotated fields.""" - copied_columns = [col.copy() for col in columns] + def __init__(self, columns: list[Column[Any]]) -> None: + self._columns = columns + @classmethod + def from_endpoint( + cls, endpoint_cls: type[RestEndpoint], name: str, namespace: dict[str, Any] + ) -> DeclaredTable: + """The automatic columns plus one per annotated field (minus exclude=True).""" + columns: list[Column[Any]] = [] + for field_name, annotation in cls._field_annotations(endpoint_cls).items(): + field_val = namespace.get(field_name) or getattr( + endpoint_cls, field_name, None + ) + field_info = field_val if isinstance(field_val, FieldInfo) else None + column = cls._column_for(name, field_name, annotation, field_info) + if column is not None: + columns.append(column) + return cls(cls._auto_columns() + columns) + + def map( + self, endpoint_cls: type[RestEndpoint], session_manager: SessionManager + ) -> None: + if endpoint_cls._model_class is not None: # noqa: SLF001 mapped already + return + + self._strip_field_infos(endpoint_cls, self._columns) table = Table( - table_name, - metadata, - *copied_columns, + self._table_name(endpoint_cls, session_manager), + session_manager.metadata, + *self._columns, extend_existing=True, keep_existing=False, ) - return table - - def _get_registry_and_metadata( - self, session_manager: Any | None = None - ) -> tuple[registry, MetaData]: - from lightapi.session_manager import _GLOBAL_METADATA, _GLOBAL_REGISTRY - - if session_manager is not None: - return session_manager.registry, session_manager.metadata - return _GLOBAL_REGISTRY, _GLOBAL_METADATA - - -class ReflectedTableBuilder: - def build( - self, - cls: type, - name: str, - meta_obj: Any, - partial: bool, - extra_columns: list[Column] | None = None, - session_manager: Any | None = None, - ) -> Table: - if session_manager is None: - raise RuntimeError( - "session_manager is required for reflection. " - "Ensure LightApi.register() was called with a properly configured app." - ) + session_manager.create_tables_now() + session_manager.registry.map_imperatively(endpoint_cls, table) + endpoint_cls._model_class = endpoint_cls # noqa: SLF001 marks it mapped - metadata = session_manager.metadata - engine = session_manager.engine - table_name = TableNameResolver.resolve(meta_obj, name, session_manager) + @staticmethod + def _field_annotations(endpoint_cls: type[RestEndpoint]) -> dict[str, Any]: + """Public annotated fields of the class and its bases, with real types.""" + # get_type_hints() resolves the string annotations that + # `from __future__ import annotations` leaves behind. + try: + resolved = get_type_hints(endpoint_cls) + except (NameError, TypeError, SyntaxError, AttributeError): + # A string annotation that cannot be evaluated; _column_for then + # reports the raw annotation as not being in the type map. + resolved = {} + + annotations: dict[str, Any] = {} + for base in reversed(endpoint_cls.__mro__): + for field_name, raw in getattr(base, "__annotations__", {}).items(): + if not field_name.startswith("_") and field_name not in AUTO_FIELDS: + annotations[field_name] = resolved.get(field_name, raw) + return annotations - is_async = hasattr(engine, "sync_engine") - if is_async: - table = self._reflect_async(engine, metadata, table_name) - else: - table = self._reflect_sync(engine, metadata, table_name) + @staticmethod + def _column_for( + endpoint_name: str, + field_name: str, + annotation: object, + field_info: FieldInfo | None, + ) -> Column[Any] | None: + """The column for one annotated field; None when the field has exclude=True.""" + raw_extra = field_info.json_schema_extra if field_info is not None else None + extra: dict[str, Any] = raw_extra if isinstance(raw_extra, dict) else {} + if extra.get("exclude"): + return None + + is_opt, inner = DeclaredTable._is_optional(annotation) + col_type = _TYPE_MAP.get(inner) + if col_type is None: + raise ConfigurationError( + f"RestEndpoint '{endpoint_name}': annotation '{inner}' on field " + f"'{field_name}' is not in the type map. " + "Add exclude=True to skip column generation." + ) + if inner is Decimal: + col_type = Numeric(scale=extra.get("decimal_places", 10)) + elif "decimal_places" in extra: + # Forwarded to pydantic, the option would make every write a 500 on a + # type it cannot apply to; refuse it where the class is defined. + raise ConfigurationError( + f"RestEndpoint '{endpoint_name}': decimal_places on field " + f"'{field_name}' needs a Decimal annotation, got '{inner}'." + ) - if partial and extra_columns: - for col in extra_columns: - if col.name not in table.c: - table.append_column(col) + col_args = ( + [ForeignKey(extra["foreign_key"])] if extra.get("foreign_key") else [] + ) + col_kwargs: dict[str, Any] = {"nullable": is_opt} + if extra.get("unique"): + col_kwargs["unique"] = True + if extra.get("index"): + col_kwargs["index"] = True + return Column(field_name, col_type, *col_args, **col_kwargs) - if partial: - FieldInfoStripper.strip(cls, list(table.c)) + @staticmethod + def _is_optional(annotation: object) -> tuple[bool, object]: + """Return (is_optional, inner_type) for an annotation.""" + origin = get_origin(annotation) + args = get_args(annotation) + if origin is Union or origin is types.UnionType: + non_none = [a for a in args if a is not type(None)] + if len(non_none) == 1 and type(None) in args: + return True, non_none[0] + return False, annotation - return table + @staticmethod + def _auto_columns() -> list[Column[Any]]: + """id, created_at, updated_at and version, present in every declared table.""" + return [ + Column("id", Integer, primary_key=True, autoincrement=True), + Column("created_at", DateTime, default=Repository.utcnow), + Column( + "updated_at", + DateTime, + default=Repository.utcnow, + onupdate=Repository.utcnow, + ), + Column("version", Integer, default=1, nullable=False), + ] + + +class ReflectedTable(TableSource): + """A table that already exists in the database (``Meta.reflect``). + + With ``TableReflection.PARTIAL`` the endpoint also declares some of those + columns as fields. + """ + + def __init__(self, reflection: TableReflection) -> None: + self._reflection = reflection + + def map( + self, endpoint_cls: type[RestEndpoint], session_manager: SessionManager + ) -> None: + if endpoint_cls._model_class is not None: # noqa: SLF001 mapped already + return - def _reflect_sync(self, engine: Any, metadata: MetaData, table_name: str) -> Table: - from sqlalchemy import inspect as sa_inspect_engine + table = self._reflect( + self._table_name(endpoint_cls, session_manager), session_manager + ) + # The Field() objects a partial endpoint declares carry its defaults and + # constraints; instrumentation needs them off the class, so keep a copy. + declared: dict[str, FieldInfo] = {} + if self._reflection is TableReflection.PARTIAL: + declared = { + name: value + for name, value in vars(endpoint_cls).items() + if isinstance(value, FieldInfo) + } + self._strip_field_infos(endpoint_cls, list(table.c)) + + session_manager.registry.map_imperatively(endpoint_cls, table) + endpoint_cls._model_class = endpoint_cls # noqa: SLF001 marks it mapped + # The schemas need the columns, which are only known after reflection. + endpoint_cls.__schema_create__, endpoint_cls.__schema_read__ = ( + SchemaFactory.build_from_reflected_table(endpoint_cls, table, declared) + ) - from lightapi.exceptions import ConfigurationError + def _reflect(self, table_name: str, session_manager: SessionManager) -> Table: + if session_manager.is_async: + return self._reflect_async( + session_manager.engine, session_manager.metadata, table_name + ) + return self._reflect_sync( + session_manager.engine, session_manager.metadata, table_name + ) - inspector = sa_inspect_engine(engine) - table_names = inspector.get_table_names() - if table_name not in table_names: + @staticmethod + def _reflect_sync(engine: Engine, metadata: MetaData, table_name: str) -> Table: + if table_name not in sa_inspect(engine).get_table_names(): raise ConfigurationError( f"Table '{table_name}' does not exist in the database." ) return Table(table_name, metadata, autoload_with=engine) - def _reflect_async(self, engine: Any, metadata: MetaData, table_name: str) -> Table: - import asyncio - import concurrent.futures - - def do_reflect(conn: Any) -> None: + @staticmethod + def _reflect_async( + engine: AsyncEngine, metadata: MetaData, table_name: str + ) -> Table: + def do_reflect(conn: Connection) -> None: metadata.reflect(bind=conn, only=[table_name]) async def async_reflect() -> None: async with engine.connect() as conn: await conn.run_sync(do_reflect) + # This runs on a loop of its own, so the connection would go back to + # the pool bound to a loop that is about to close and asyncpg refuses + # it on the first request (the B7-003 mechanism). A StaticPool is the + # one in-memory connection: disposing it would drop the database. + if not isinstance(engine.pool, StaticPool): + await engine.dispose() try: asyncio.get_running_loop() - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - pool.submit(asyncio.run, async_reflect()).result() except RuntimeError: asyncio.run(async_reflect()) + else: + # Already inside a loop (tests, notebooks): reflect on a loop of its own. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(asyncio.run, async_reflect()).result() if table_name not in metadata.tables: - from lightapi.exceptions import ConfigurationError - raise ConfigurationError(f"Table '{table_name}' could not be reflected.") return metadata.tables[table_name] - - -class TableMapper: - def __init__(self) -> None: - self._imperative_builder = ImperativeTableBuilder() - self._reflected_builder = ReflectedTableBuilder() - - def map_imperatively( - self, - cls: type, - name: str, - columns: list[Column], - meta_obj: Any, - session_manager: Any | None = None, - ) -> None: - if getattr(cls, "_model_class", None) is not None: - return - - table = self._imperative_builder.build( - cls, name, columns, meta_obj, session_manager - ) - registry_obj, metadata = self._get_registry_and_metadata(session_manager) - - if session_manager is not None and hasattr(session_manager, "engine"): - engine = session_manager.engine - is_async = getattr(session_manager, "_is_async", False) - if is_async: - pass - elif hasattr(engine, "sync_engine"): - engine = engine.sync_engine - metadata.create_all(engine) - else: - metadata.create_all(engine) - - registry_obj.map_imperatively(cls, table) - cls._model_class = cls # type: ignore[attr-defined] - - def map_reflected( - self, - cls: type, - name: str, - meta_obj: Any, - partial: bool, - extra_columns: list[Column] | None = None, - session_manager: Any | None = None, - ) -> None: - table = self._reflected_builder.build( - cls, name, meta_obj, partial, extra_columns, session_manager - ) - registry = session_manager.registry - - try: - from sqlalchemy import inspect as sa_inspect - - sa_inspect(cls) - cls._model_class = cls # type: ignore[attr-defined] - return - except Exception: - pass - - registry.map_imperatively(cls, table) - cls._model_class = cls # type: ignore[attr-defined] - - def _get_registry_and_metadata( - self, session_manager: Any | None = None - ) -> tuple[registry, MetaData]: - from lightapi.session_manager import _GLOBAL_METADATA, _GLOBAL_REGISTRY - - if session_manager is not None: - return session_manager.registry, session_manager.metadata - return _GLOBAL_REGISTRY, _GLOBAL_METADATA - - -_mapper = TableMapper() - - -def map_imperatively( - cls: type, - name: str, - columns: list[Column], - meta_obj: Any, - session_manager: Any | None = None, -) -> None: - _mapper.map_imperatively(cls, name, columns, meta_obj, session_manager) - - -def map_reflected( - cls: type, - name: str, - meta_obj: Any, - partial: bool, - extra_columns: list[Column] | None = None, - session_manager: Any | None = None, -) -> None: - _mapper.map_reflected(cls, name, meta_obj, partial, extra_columns, session_manager) diff --git a/lightapi/yaml_config.py b/lightapi/yaml_config.py new file mode 100644 index 0000000..7db2ffa --- /dev/null +++ b/lightapi/yaml_config.py @@ -0,0 +1,611 @@ +"""Declarative YAML configuration: the document's models and how they become an app. + +Declarative format:: + + database: + url: postgresql://user:pass@localhost/shop + + defaults: + authentication: { backend: JWTAuthentication, permission: IsAuthenticated } + pagination: { style: page_number, page_size: 20 } + + endpoints: + - route: /announcements + fields: + title: { type: str, min_length: 1, max_length: 200 } + pinned: { type: bool, default: false } + meta: + methods: [GET, POST] + authentication: { permission: AllowAny } + filtering: { fields: [pinned], ordering: [created_at] } + + middleware: [CORSMiddleware, RequestIdMiddleware] +""" + +from __future__ import annotations + +import datetime +import importlib +import inspect +import os +from collections.abc import Callable +from decimal import Decimal +from pathlib import Path +from typing import TYPE_CHECKING, Any, Final + +import yaml +from pydantic import ( + BaseModel, + ConfigDict, + ValidationError, + field_validator, + model_validator, +) + +from lightapi.authentication import ( + AllowAny, + BasePermission, + BasicAuthentication, + IsAdminUser, + IsAuthenticated, + JWTAuthentication, +) +from lightapi.config import Authentication, Cache, Filtering, Pagination, Serializer +from lightapi.constants import DEFAULT_CACHE_TTL, DEFAULT_PAGE_SIZE, HTTP_METHOD_MIXINS +from lightapi.core import AuthenticationMiddleware, CORSMiddleware, Middleware +from lightapi.exceptions import ConfigurationError +from lightapi.fields import Field +from lightapi.filters import FieldFilter, OrderingFilter, SearchFilter +from lightapi.pagination import PaginationStyle +from lightapi.rest import RestEndpoint + +if TYPE_CHECKING: + from pydantic.fields import FieldInfo + + from lightapi.lightapi import LightApi + +YAML_FIELD_TYPES: Final[dict[str, type]] = { + "str": str, + "int": int, + "float": float, + "bool": bool, + "datetime": datetime.datetime, + "Decimal": Decimal, + "decimal": Decimal, +} + +# Built-ins a YAML document may name without a dotted path. +NAME_REGISTRY: Final[dict[str, type]] = { + "JWTAuthentication": JWTAuthentication, + "BasicAuthentication": BasicAuthentication, + "AllowAny": AllowAny, + "IsAuthenticated": IsAuthenticated, + "IsAdminUser": IsAdminUser, + "FieldFilter": FieldFilter, + "SearchFilter": SearchFilter, + "OrderingFilter": OrderingFilter, + "Middleware": Middleware, + "CORSMiddleware": CORSMiddleware, + "AuthenticationMiddleware": AuthenticationMiddleware, + **HTTP_METHOD_MIXINS, +} + +# Keys of a field spec that are forwarded to Field() as they are. +FIELD_CONSTRAINT_KEYS: Final = frozenset( + { + "min_length", + "max_length", + "gt", + "ge", + "lt", + "le", + "pattern", + "unique", + "index", + "foreign_key", + "decimal_places", + "exclude", + } +) + + +class _Document(BaseModel): + """A block of the YAML document: a key the format does not know is an error. + + The docs promise that a typo fails at load time; pydantic's default would + drop it and serve an app without the feature the user thought they configured. + """ + + model_config = ConfigDict(extra="forbid") + + +class DatabaseConfig(_Document): + """Nested database block: database: { url: ... }""" + + url: str + + @field_validator("url", mode="before") + @classmethod + def substitute_env(cls, value: str) -> str: + """Replace ${VAR} with the environment variable value.""" + if value.startswith("${") and value.endswith("}"): + var = value[2:-1] + resolved = os.environ.get(var) + if not resolved: + raise ConfigurationError( + f"Environment variable '{var}' is not set " + "(required by lightapi.yaml)." + ) + return resolved + return value + + +class AuthLoginConfig(_Document): + """Login/auth block: auth: { auth_path: ..., login_validator: ... }. + + When using JWTAuthentication or BasicAuthentication, login_validator is required. + It can be specified as a dotted path (e.g. myapp.validators.validate_login) + or passed as an override to from_config(login_validator=...). + """ + + auth_path: str = "/auth" + login_validator: str | None = None + + +class AuthConfig(_Document): + """Authentication block used in defaults and per-endpoint meta.""" + + backend: str | None = None + permission: str | dict[str, str] | None = None + jwt_expiration: int | None = None + jwt_extra_claims: list[str] | None = None + jwt_algorithm: str | None = None + + def merged_over(self, defaults: AuthConfig | None) -> AuthConfig: + """These settings, with every one left unset taken from ``defaults``.""" + if defaults is None: + return self + return defaults.model_copy(update=self.model_dump(exclude_none=True)) + + def resolved_permission( + self, + ) -> type[BasePermission] | dict[str, type[BasePermission]] | None: + """The permission class, or {method: class} for a per-method dict.""" + if isinstance(self.permission, dict): + return { + method: LightAPIConfig.resolve_name(name) + for method, name in self.permission.items() + } + if isinstance(self.permission, str): + permission: type[BasePermission] = LightAPIConfig.resolve_name( + self.permission + ) + return permission + return None + + def to_authentication( + self, permission: type[BasePermission] | dict[str, type[BasePermission]] | None + ) -> Authentication: + return Authentication( + backend=LightAPIConfig.resolve_name(self.backend) if self.backend else None, + permission=permission, + jwt_expiration=self.jwt_expiration, + jwt_extra_claims=self.jwt_extra_claims, + jwt_algorithm=self.jwt_algorithm, + ) + + +class FilteringConfig(_Document): + """Filtering block inside meta.""" + + backends: list[str] = [] + fields: list[str] = [] + search: list[str] = [] + ordering: list[str] = [] + + def to_filtering(self) -> Filtering: + """Named backends, or the ones the populated lists call for.""" + backends: list[type] = [LightAPIConfig.resolve_name(b) for b in self.backends] + if not backends: + if self.fields: + backends.append(FieldFilter) + if self.search: + backends.append(SearchFilter) + if self.ordering: + backends.append(OrderingFilter) + + return Filtering( + backends=backends or None, + fields=self.fields or None, + search=self.search or None, + ordering=self.ordering or None, + ) + + +class PaginationConfig(_Document): + """Pagination block used in defaults and per-endpoint meta.""" + + style: str = PaginationStyle.PAGE_NUMBER.value + page_size: int = DEFAULT_PAGE_SIZE + + def to_pagination(self) -> Pagination: + return Pagination(style=self.style, page_size=self.page_size) + + +class DefaultsConfig(_Document): + """Global defaults applied to all endpoints unless overridden.""" + + authentication: AuthConfig | None = None + pagination: PaginationConfig | None = None + + +class MethodAuthConfig(_Document): + """Per-method authentication override inside meta.methods dict.""" + + authentication: AuthConfig | None = None + + +class CacheConfig(_Document): + """Cache block inside meta: cache: { ttl: 60 }""" + + ttl: int = DEFAULT_CACHE_TTL + + +class SerializerConfig(_Document): + """Serializer block inside meta. + + Use ``fields`` for a unified list, or ``read``/``write`` for per-verb lists. + """ + + fields: list[str] | None = None + read: list[str] | None = None + write: list[str] | None = None + + +class MetaConfig(_Document): + """meta: block inside a declarative endpoint entry.""" + + # methods can be a list ["GET", "POST"] or a dict {GET: {...}, DELETE: {...}} + methods: list[str] | dict[str, MethodAuthConfig | None] = [] + authentication: AuthConfig | None = None + filtering: FilteringConfig | None = None + pagination: PaginationConfig | None = None + cache: CacheConfig | None = None + serializer: SerializerConfig | None = None + table: str | None = None # custom table name (required when reflect: true) + + def build_meta_class(self, defaults: DefaultsConfig, *, reflect: bool) -> type: + """The ``Meta`` inner class of the endpoint this block describes.""" + attrs: dict[str, Any] = {} + + if reflect: + attrs["reflect"] = True + if self.table: + attrs["table"] = self.table + return type("Meta", (), attrs) + + if isinstance(self.methods, dict): + auth = self._per_method_authentication(self.methods, defaults) + else: + auth = self._endpoint_authentication(defaults) + if auth is not None: + attrs["authentication"] = auth + + if self.filtering is not None: + attrs["filtering"] = self.filtering.to_filtering() + + # Pagination: endpoint meta overrides defaults + pagination = self.pagination or defaults.pagination + if pagination is not None: + attrs["pagination"] = pagination.to_pagination() + + if self.cache is not None: + attrs["cache"] = Cache(ttl=self.cache.ttl) + + if self.serializer is not None: + attrs["serializer"] = Serializer( + fields=self.serializer.fields, + read=self.serializer.read, + write=self.serializer.write, + ) + + if self.table: + attrs["table"] = self.table + + return type("Meta", (), attrs) + + def endpoint_bases(self) -> tuple[type, ...]: + """RestEndpoint plus the HttpMethod mixins named by ``methods``.""" + method_names = ( + list(self.methods.keys()) + if isinstance(self.methods, dict) + else self.methods + ) + bases: list[type] = [RestEndpoint] + for name in method_names: + mixin = HTTP_METHOD_MIXINS.get(name) + if mixin is None: + raise ConfigurationError( + f"Unknown HTTP method '{name}' in methods list." + ) + bases.append(mixin) + return tuple(bases) + + def _endpoint_authentication( + self, defaults: DefaultsConfig + ) -> Authentication | None: + """Endpoint settings win; the defaults fill whatever the endpoint left unset.""" + settings = (self.authentication or AuthConfig()).merged_over( + defaults.authentication + ) + if settings.backend is None and settings.permission is None: + return None + return settings.to_authentication(settings.resolved_permission()) + + def _per_method_authentication( + self, methods: dict[str, MethodAuthConfig | None], defaults: DefaultsConfig + ) -> Authentication | None: + """Authentication for ``methods: {GET: {...}, DELETE: {...}}``. + + Each method takes its own permission, else the endpoint's, else the default. + """ + default_auth = defaults.authentication + default_permission = default_auth.permission if default_auth else None + + permissions: dict[str, type] = {} + for method, method_cfg in methods.items(): + method_auth = (method_cfg.authentication if method_cfg else None) or ( + self.authentication + ) + name = self._permission_name(method, method_auth, default_permission) + if name: + permissions[method] = LightAPIConfig.resolve_name(name) + + if not permissions: + return None + settings = (self.authentication or AuthConfig()).merged_over(default_auth) + return settings.to_authentication(permissions) + + @staticmethod + def _permission_name( + method: str, + method_auth: AuthConfig | None, + default_permission: str | dict[str, str] | None, + ) -> str | None: + own = method_auth.permission if method_auth else None + if isinstance(own, str): + return own + if isinstance(default_permission, dict): + return default_permission.get(method) + return default_permission + + +class FieldSpec(BaseModel): + """Single field definition inside fields:.""" + + type: str + optional: bool = False + # All remaining keys forwarded to Field() as pydantic constraints + model_config = {"extra": "allow"} + + @field_validator("type") + @classmethod + def type_must_be_known(cls, v: str) -> str: + if v not in YAML_FIELD_TYPES: + raise ValueError( + f"Unknown field type '{v}'. Valid types: {sorted(YAML_FIELD_TYPES)}" + ) + return v + + @model_validator(mode="after") + def extra_keys_must_be_constraints(self) -> FieldSpec: + """A key that is not a constraint (`min_lenght`) would be dropped silently.""" + unknown = sorted( + set(self.model_extra or ()) - FIELD_CONSTRAINT_KEYS - {"default"} + ) + if unknown: + raise ValueError( + f"Unknown field option(s) {unknown}. " + f"Valid: {sorted(FIELD_CONSTRAINT_KEYS | {'default'})}" + ) + return self + + @property + def annotation(self) -> object: + """The field's type, or ``type | None`` for an optional one.""" + py_type = YAML_FIELD_TYPES[self.type] + return py_type | None if self.optional else py_type + + def field_info(self) -> FieldInfo | None: + """The Field() for this spec; None for a constraint-less, default-less field. + + Such a field stays annotation-only on purpose: an Ellipsis placeholder + would survive the FieldInfo stripping and stop SQLAlchemy from mapping the + column, so its value would silently never be inserted. + """ + extra = self.model_extra or {} + kwargs = {k: v for k, v in extra.items() if k in FIELD_CONSTRAINT_KEYS} + if "default" in extra: + kwargs["default"] = extra["default"] + elif self.optional: + kwargs["default"] = None + return Field(**kwargs) if kwargs else None + + +class EndpointConfig(_Document): + """A single endpoint entry.""" + + route: str + fields: dict[str, FieldSpec] = {} + reflect: bool = False + meta: MetaConfig = MetaConfig() + + @model_validator(mode="after") + def require_route(self) -> EndpointConfig: + if not self.route: + raise ValueError("Each endpoint must have a 'route'.") + return self + + @property + def effective_route(self) -> str: + return self.route.strip() + + @property + def class_name(self) -> str: + """/announcements -> AnnouncementsEndpoint, /api/v1/items -> ItemsEndpoint.""" + slug = self.effective_route.strip("/").split("/")[-1].replace("-", "_") + return slug.title().replace("_", "") + "Endpoint" + + def build_endpoint_class(self, defaults: DefaultsConfig) -> type: + """The RestEndpoint subclass this entry declares.""" + annotations: dict[str, Any] = {} + class_attrs: dict[str, Any] = {"__annotations__": annotations} + for field_name, spec in self.fields.items(): + annotations[field_name] = spec.annotation + field_info = spec.field_info() + if field_info is not None: + class_attrs[field_name] = field_info + + class_attrs["Meta"] = self.meta.build_meta_class(defaults, reflect=self.reflect) + # type() runs RestEndpointMeta.__new__, which maps fields and Meta. + return type(self.class_name, self.meta.endpoint_bases(), class_attrs) + + +class LightAPIConfig(_Document): + """Root YAML document schema.""" + + database: DatabaseConfig | None = None + cors_origins: list[str] = [] + defaults: DefaultsConfig = DefaultsConfig() + endpoints: list[EndpointConfig] = [] + middleware: list[str] = [] + auth: AuthLoginConfig | None = None + mode: str | None = None # "sync" or "async"; auto-detected when omitted + + @property + def effective_database_url(self) -> str | None: + return self.database.url if self.database else None + + @classmethod + def from_file(cls, config_path: str) -> LightAPIConfig: + """The validated document at ``config_path``.""" + with Path(config_path).open() as fh: + raw: dict[str, Any] = yaml.safe_load(fh) or {} + try: + return cls.model_validate(raw) + except ValidationError as exc: + # Location and message only: pydantic's own text prints the input, + # and under a typo like `databse:` that input is the database URL. + problems = "\n".join( + f" {'.'.join(str(part) for part in error['loc']) or ''}: " + f"{error['msg']}" + for error in exc.errors(include_input=False, include_url=False) + ) + raise ConfigurationError( + f"Invalid lightapi.yaml — {exc.error_count()} error(s):\n{problems}" + ) from exc + + def build_app( + self, + app_cls: type[LightApi], + **overrides: Any, # noqa: ANN401 LightApi constructor kwargs, forwarded + ) -> LightApi: + """A configured ``app_cls`` instance with every endpoint registered. + + Kwargs override YAML-derived values (e.g. engine=..., database_url=...). + """ + middlewares = [self.resolve_name(name) for name in self.middleware] + constructor_kwargs: dict[str, Any] = { + "database_url": self.effective_database_url or None, + "cors_origins": self.cors_origins or None, + "middlewares": middlewares or None, + } + if self.mode is not None: + constructor_kwargs["mode"] = self.mode + if self.auth: + constructor_kwargs["auth_path"] = self.auth.auth_path + if self.auth.login_validator: + constructor_kwargs["login_validator"] = self.resolve_callable( + self.auth.login_validator + ) + constructor_kwargs.update(overrides) + + instance = app_cls(**constructor_kwargs) + mapping = { + entry.effective_route: entry.build_endpoint_class(self.defaults) + for entry in self.endpoints + } + if mapping: + instance.register(mapping) + return instance + + @staticmethod + def resolve_name( + name: str, + ) -> Any: # noqa: ANN401 whatever class the dotted path names + """The class a document names: a built-in by short name, else a dotted path.""" + if name in NAME_REGISTRY: + return NAME_REGISTRY[name] + if "." in name: + module_path, class_name = name.rsplit(".", 1) + try: + mod = importlib.import_module(module_path) + return getattr(mod, class_name) + except (ImportError, AttributeError) as exc: + raise ConfigurationError(f"Cannot resolve '{name}': {exc}") from exc + raise ConfigurationError( + f"Unknown class name '{name}'. " + "Use a fully dotted import path for custom classes." + ) + + @staticmethod + def resolve_callable(dotted_path: str) -> Callable[..., Any]: + """The login validator at a dotted path: sync, takes (username, password).""" + if "." not in dotted_path: + raise ConfigurationError( + f"login_validator must be a dotted path (e.g. myapp.validators.check), " + f"got '{dotted_path}'" + ) + module_path, attr_name = dotted_path.rsplit(".", 1) + try: + mod = importlib.import_module(module_path) + fn = getattr(mod, attr_name) + except (ImportError, AttributeError) as exc: + raise ConfigurationError( + f"Cannot resolve login_validator '{dotted_path}': {exc}" + ) from exc + if not callable(fn): + raise ConfigurationError( + f"login_validator '{dotted_path}' is not callable." + ) + + if inspect.iscoroutinefunction(fn): + raise ConfigurationError( + f"login_validator '{dotted_path}' is async, " + "but sync function required. " + "Login validation must be a synchronous function." + ) + validator: Callable[..., Any] = fn + + try: + sig = inspect.signature(fn) + except ValueError: + # Some callables (e.g., builtins) don't have inspectable signatures + return validator + + required_params = sum( + 1 + for param in sig.parameters.values() + if param.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + and param.default is inspect.Parameter.empty + ) + if required_params != 2: + raise ConfigurationError( + f"login_validator '{dotted_path}' must accept exactly 2 required " + f"positional parameters (username, password), got {required_params}" + ) + return validator diff --git a/lightapi/yaml_loader.py b/lightapi/yaml_loader.py deleted file mode 100644 index d1b670d..0000000 --- a/lightapi/yaml_loader.py +++ /dev/null @@ -1,705 +0,0 @@ -"""Declarative YAML configuration loader for LightAPI v2. - -Uses Pydantic v2 models to validate the YAML structure, then translates -the validated model into RestEndpoint subclasses and LightApi configuration. - -Declarative format:: - - database: - url: postgresql://user:pass@localhost/shop - - defaults: - authentication: { backend: JWTAuthentication, permission: IsAuthenticated } - pagination: { style: page_number, page_size: 20 } - - endpoints: - - route: /announcements - fields: - title: { type: str, min_length: 1, max_length: 200 } - pinned: { type: bool, default: false } - meta: - methods: [GET, POST] - authentication: { permission: AllowAny } - filtering: { fields: [pinned], ordering: [created_at] } - - middleware: [CORSMiddleware, RequestIdMiddleware] -""" - -from __future__ import annotations - -import datetime -import importlib -import os -from decimal import Decimal -from typing import Any, Union - -from pydantic import BaseModel, field_validator, model_validator - -from lightapi.exceptions import ConfigurationError - -# ───────────────────────────────────────────────────────────────────────────── -# String → class registry (everything a YAML author would reference by name) -# ───────────────────────────────────────────────────────────────────────────── - - -def _build_name_registry() -> dict[str, type]: - from lightapi.auth import ( - AllowAny, - BasicAuthentication, - IsAdminUser, - IsAuthenticated, - JWTAuthentication, - ) - from lightapi.core import AuthenticationMiddleware, CORSMiddleware, Middleware - from lightapi.filters import FieldFilter, OrderingFilter, SearchFilter - from lightapi.methods import HttpMethod - - return { - # Auth backends - "JWTAuthentication": JWTAuthentication, - "BasicAuthentication": BasicAuthentication, - # Permissions - "AllowAny": AllowAny, - "IsAuthenticated": IsAuthenticated, - "IsAdminUser": IsAdminUser, - # Filter backends - "FieldFilter": FieldFilter, - "SearchFilter": SearchFilter, - "OrderingFilter": OrderingFilter, - # Middleware - "Middleware": Middleware, - "CORSMiddleware": CORSMiddleware, - "AuthenticationMiddleware": AuthenticationMiddleware, - # HttpMethod mixins (for bases resolution) - "GET": HttpMethod.GET, - "POST": HttpMethod.POST, - "PUT": HttpMethod.PUT, - "PATCH": HttpMethod.PATCH, - "DELETE": HttpMethod.DELETE, - } - - -def _resolve_callable(dotted_path: str) -> Any: - """Resolve a dotted path like 'myapp.validators.validate_login' to a callable.""" - if "." not in dotted_path: - raise ConfigurationError( - f"login_validator must be a dotted path (e.g. myapp.validators.check), " - f"got '{dotted_path}'" - ) - module_path, attr_name = dotted_path.rsplit(".", 1) - try: - mod = importlib.import_module(module_path) - fn = getattr(mod, attr_name) - except (ImportError, AttributeError) as exc: - raise ConfigurationError( - f"Cannot resolve login_validator '{dotted_path}': {exc}" - ) from exc - if not callable(fn): - raise ConfigurationError(f"login_validator '{dotted_path}' is not callable.") - - # Validate signature: must be sync function with exactly 2 positional args - import inspect - - if inspect.iscoroutinefunction(fn): - raise ConfigurationError( - f"login_validator '{dotted_path}' is async, but sync function required. " - f"Login validation must be a synchronous function." - ) - - try: - sig = inspect.signature(fn) - except ValueError: - # Some callables (e.g., builtins) don't have inspectable signatures - return fn - - # Count required positional parameters - required_params = 0 - for param in sig.parameters.values(): - if param.kind in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ): - if param.default is inspect.Parameter.empty: - required_params += 1 - - if required_params != 2: - raise ConfigurationError( - f"login_validator '{dotted_path}' must accept exactly 2 required " - f"positional parameters (username, password), got {required_params}" - ) - - return fn - - -def _resolve_name(name: str) -> type: - """Resolve a class name string to a class. - - Tries the built-in registry first, then falls back to dotted import path - (e.g. 'myapp.middleware.RequestIdMiddleware'). - """ - registry = _build_name_registry() - if name in registry: - return registry[name] - # Dotted path fallback - if "." in name: - module_path, class_name = name.rsplit(".", 1) - try: - mod = importlib.import_module(module_path) - return getattr(mod, class_name) - except (ImportError, AttributeError) as exc: - raise ConfigurationError(f"Cannot resolve '{name}': {exc}") from exc - raise ConfigurationError( - f"Unknown class name '{name}'. " - "Use a fully dotted import path for custom classes." - ) - - -# ───────────────────────────────────────────────────────────────────────────── -# Type map for declarative field definitions -# ───────────────────────────────────────────────────────────────────────────── - -_YAML_TYPE_MAP: dict[str, type] = { - "str": str, - "int": int, - "float": float, - "bool": bool, - "datetime": datetime.datetime, - "Decimal": Decimal, - "decimal": Decimal, -} - -# ───────────────────────────────────────────────────────────────────────────── -# Pydantic v2 models — the YAML schema -# ───────────────────────────────────────────────────────────────────────────── - - -class DatabaseConfig(BaseModel): - """Nested database block: database: { url: ... }""" - - url: str - - @field_validator("url", mode="before") - @classmethod - def substitute_env(cls, v: str) -> str: - return _substitute_env(v) - - -class AuthLoginConfig(BaseModel): - """Login/auth block: auth: { auth_path: ..., login_validator: ... }. - - When using JWTAuthentication or BasicAuthentication, login_validator is required. - It can be specified as a dotted path (e.g. myapp.validators.validate_login) - or passed as an override to from_config(login_validator=...). - """ - - auth_path: str = "/auth" - login_validator: str | None = None - - -class AuthConfig(BaseModel): - """Authentication block used in defaults and per-endpoint meta.""" - - backend: str | None = None - permission: Union[str, dict[str, str], None] = None - jwt_expiration: int | None = None - jwt_extra_claims: list[str] | None = None - jwt_algorithm: str | None = None - - -class FilteringConfig(BaseModel): - """Filtering block inside meta.""" - - backends: list[str] = [] - fields: list[str] = [] - search: list[str] = [] - ordering: list[str] = [] - - -class PaginationConfig(BaseModel): - """Pagination block used in defaults and per-endpoint meta.""" - - style: str = "page_number" - page_size: int = 20 - - -class DefaultsConfig(BaseModel): - """Global defaults applied to all endpoints unless overridden.""" - - authentication: AuthConfig | None = None - pagination: PaginationConfig | None = None - - -class MethodAuthConfig(BaseModel): - """Per-method authentication override inside meta.methods dict.""" - - authentication: AuthConfig | None = None - - -class CacheConfig(BaseModel): - """Cache block inside meta: cache: { ttl: 60 }""" - - ttl: int = 60 - - -class SerializerConfig(BaseModel): - """Serializer block inside meta. - - Use ``fields`` for a unified list, or ``read``/``write`` for per-verb lists. - """ - - fields: list[str] | None = None - read: list[str] | None = None - write: list[str] | None = None - - -class MetaConfig(BaseModel): - """meta: block inside a declarative endpoint entry.""" - - # methods can be a list ["GET", "POST"] or a dict {GET: {...}, DELETE: {...}} - methods: Union[list[str], dict[str, MethodAuthConfig | None]] = [] - authentication: AuthConfig | None = None - filtering: FilteringConfig | None = None - pagination: PaginationConfig | None = None - cache: CacheConfig | None = None - serializer: SerializerConfig | None = None - table: str | None = None # custom table name (required when reflect: true) - - -class FieldSpec(BaseModel): - """Single field definition inside fields:.""" - - type: str - optional: bool = False - # All remaining keys forwarded to Field() as pydantic constraints - model_config = {"extra": "allow"} - - @field_validator("type") - @classmethod - def type_must_be_known(cls, v: str) -> str: - if v not in _YAML_TYPE_MAP: - raise ValueError( - f"Unknown field type '{v}'. Valid types: {sorted(_YAML_TYPE_MAP)}" - ) - return v - - -class EndpointConfig(BaseModel): - """A single endpoint entry.""" - - route: str - fields: dict[str, FieldSpec] = {} - reflect: bool = False - meta: MetaConfig = MetaConfig() - - @model_validator(mode="after") - def require_route(self) -> "EndpointConfig": - if not self.route: - raise ValueError("Each endpoint must have a 'route'.") - return self - - @property - def effective_route(self) -> str: - return self.route.strip() - - -class LightAPIConfig(BaseModel): - """Root YAML document schema.""" - - database: DatabaseConfig | None = None - cors_origins: list[str] = [] - defaults: DefaultsConfig = DefaultsConfig() - endpoints: list[EndpointConfig] = [] - middleware: list[str] = [] - auth: AuthLoginConfig | None = None - mode: str | None = None # "sync" | "async" — auto-detected when omitted - - @property - def effective_database_url(self) -> str | None: - return self.database.url if self.database else None - - -# ───────────────────────────────────────────────────────────────────────────── -# Translation: validated Pydantic model → LightAPI objects -# ───────────────────────────────────────────────────────────────────────────── - - -def _substitute_env(value: str) -> str: - """Replace ${VAR} with the environment variable value.""" - if value.startswith("${") and value.endswith("}"): - var = value[2:-1] - resolved = os.environ.get(var) - if not resolved: - raise ConfigurationError( - f"Environment variable '{var}' is not set (required by lightapi.yaml)." - ) - return resolved - return value - - -def _make_authentication( - auth_cfg: AuthConfig | None, - defaults_auth: AuthConfig | None, -) -> Any: - # Merge: explicit cfg wins over defaults, defaults fill gaps - merged_backend = None - merged_permission = None - merged_jwt_expiration = None - merged_jwt_extra_claims = None - merged_jwt_algorithm = None - - for source in (defaults_auth, auth_cfg): - if source is None: - continue - if source.backend is not None: - merged_backend = source.backend - if source.permission is not None: - merged_permission = source.permission - if source.jwt_expiration is not None: - merged_jwt_expiration = source.jwt_expiration - if source.jwt_extra_claims is not None: - merged_jwt_extra_claims = source.jwt_extra_claims - if source.jwt_algorithm is not None: - merged_jwt_algorithm = source.jwt_algorithm - - if merged_backend is None and merged_permission is None: - return None - - backend_cls = _resolve_name(merged_backend) if merged_backend else None - - if isinstance(merged_permission, dict): - # Per-method permission dict: {GET: IsAuthenticated, DELETE: IsAdminUser} - permission = { - method: _resolve_name(perm) for method, perm in merged_permission.items() - } - elif isinstance(merged_permission, str): - permission = _resolve_name(merged_permission) - else: - permission = None - - from lightapi.config import Authentication - - return Authentication( - backend=backend_cls, - permission=permission, - jwt_expiration=merged_jwt_expiration, - jwt_extra_claims=merged_jwt_extra_claims, - jwt_algorithm=merged_jwt_algorithm, - ) - - -def _make_filtering(filtering_cfg: FilteringConfig | None) -> Any: - """Build a Filtering instance from a FilteringConfig.""" - if filtering_cfg is None: - return None - from lightapi.config import Filtering - from lightapi.filters import FieldFilter, OrderingFilter, SearchFilter - - # Auto-select backends based on which lists are populated - backends: list[type] = list( - [_resolve_name(b) for b in filtering_cfg.backends] - if filtering_cfg.backends - else [] - ) - if not backends: - if filtering_cfg.fields: - backends.append(FieldFilter) - if filtering_cfg.search: - backends.append(SearchFilter) - if filtering_cfg.ordering: - backends.append(OrderingFilter) - - return Filtering( - backends=backends or None, - fields=filtering_cfg.fields or None, - search=filtering_cfg.search or None, - ordering=filtering_cfg.ordering or None, - ) - - -def _make_pagination(pag_cfg: PaginationConfig | None) -> Any: - """Build a Pagination instance from a PaginationConfig.""" - if pag_cfg is None: - return None - from lightapi.config import Pagination - - return Pagination(style=pag_cfg.style, page_size=pag_cfg.page_size) - - -def _build_meta_class( - meta: MetaConfig, - defaults: DefaultsConfig, - reflect: bool, -) -> type: - """Construct a Meta inner class for dynamic RestEndpoint subclasses.""" - attrs: dict[str, Any] = {} - - if reflect: - attrs["reflect"] = True - if meta.table: - attrs["table"] = meta.table - return type("Meta", (), attrs) - - # Authentication — handle per-method dict (methods as dict form) - if isinstance(meta.methods, dict): - # Build per-method permission dict from the methods dict - permission_map: dict[str, type] = {} - method_auth_default = meta.authentication # endpoint-level auth override - - # Get default permission from defaults - default_permission = None - if defaults.authentication and defaults.authentication.permission: - default_permission = defaults.authentication.permission - - for method, method_cfg in meta.methods.items(): - cfg_auth = method_cfg.authentication if method_cfg else None - src_auth = cfg_auth or method_auth_default - - # Determine permission for this method - method_permission = None - - # First, check method-specific auth - if src_auth and src_auth.permission: - perm = src_auth.permission - if isinstance(perm, str): - method_permission = _resolve_name(perm) - # Note: per-method auth in YAML dict can't have dict permissions - # only string permissions are supported in this path - - # If no method-specific permission, check default permission - if method_permission is None and default_permission is not None: - if isinstance(default_permission, dict): - # Default permission is a dict: check for method-specific default - if method in default_permission: - perm = default_permission[method] - if isinstance(perm, str): - method_permission = _resolve_name(perm) - else: - # Default permission is a string or class - if isinstance(default_permission, str): - method_permission = _resolve_name(default_permission) - else: - method_permission = default_permission - - if method_permission is not None: - permission_map[method] = method_permission - - if permission_map: - # Merge auth settings similar to _make_authentication - merged_backend = None - merged_jwt_expiration = None - merged_jwt_extra_claims = None - merged_jwt_algorithm = None - - for source in (defaults.authentication, meta.authentication): - if source is None: - continue - if source.backend is not None: - merged_backend = source.backend - if source.jwt_expiration is not None: - merged_jwt_expiration = source.jwt_expiration - if source.jwt_extra_claims is not None: - merged_jwt_extra_claims = source.jwt_extra_claims - if source.jwt_algorithm is not None: - merged_jwt_algorithm = source.jwt_algorithm - - from lightapi.config import Authentication - - attrs["authentication"] = Authentication( - backend=_resolve_name(merged_backend) if merged_backend else None, - permission=permission_map, - jwt_expiration=merged_jwt_expiration, - jwt_extra_claims=merged_jwt_extra_claims, - jwt_algorithm=merged_jwt_algorithm, - ) - else: - # Simple auth: endpoint overrides defaults - auth = _make_authentication(meta.authentication, defaults.authentication) - if auth is not None: - attrs["authentication"] = auth - - filtering = _make_filtering(meta.filtering) - if filtering is not None: - attrs["filtering"] = filtering - - # Pagination: endpoint meta overrides defaults - pag_cfg = meta.pagination or defaults.pagination - pagination = _make_pagination(pag_cfg) - if pagination is not None: - attrs["pagination"] = pagination - - # Cache - if meta.cache is not None: - from lightapi.config import Cache - - attrs["cache"] = Cache(ttl=meta.cache.ttl) - - # Serializer - if meta.serializer is not None: - from lightapi.config import Serializer - - ser_cfg = meta.serializer - attrs["serializer"] = Serializer( - fields=ser_cfg.fields, - read=ser_cfg.read, - write=ser_cfg.write, - ) - - if meta.table: - attrs["table"] = meta.table - - return type("Meta", (), attrs) - - -def _resolve_methods_bases(meta: MetaConfig) -> tuple[type, ...]: - """Return HttpMethod mixin bases for both list and dict forms of ``methods``.""" - from lightapi.rest import RestEndpoint - - if isinstance(meta.methods, dict): - # Dict form: {GET: {auth: ...}, POST: {auth: ...}} — keys are the allowed verbs. - method_list = list(meta.methods.keys()) - elif isinstance(meta.methods, list): - method_list = meta.methods - else: - return (RestEndpoint,) - - if not method_list: - return (RestEndpoint,) - - registry = _build_name_registry() - bases: list[type] = [RestEndpoint] - for m in method_list: - mixin = registry.get(m) - if mixin is None: - raise ConfigurationError(f"Unknown HTTP method '{m}' in methods list.") - bases.append(mixin) - return tuple(bases) - - -def _build_endpoint_class(entry: EndpointConfig, defaults: DefaultsConfig) -> type: - """Dynamically build a RestEndpoint subclass from a declarative endpoint entry.""" - from typing import Optional - - from lightapi.fields import Field - - route = entry.effective_route - # /announcements → AnnouncementsEndpoint, /api/v1/items → ItemsEndpoint - slug = route.strip("/").split("/")[-1].replace("-", "_") - class_name = slug.title().replace("_", "") + "Endpoint" - - annotations: dict[str, Any] = {} - class_attrs: dict[str, Any] = {"__annotations__": annotations} - - for field_name, spec in entry.fields.items(): - py_type = _YAML_TYPE_MAP[spec.type] - if spec.optional: - py_type = Optional[py_type] # type: ignore[assignment] - - # Forward all extra keys (min_length, max_length, gt, default, …) to Field() - extra = spec.model_extra or {} - constraint_keys = { - "min_length", - "max_length", - "gt", - "ge", - "lt", - "le", - "pattern", - "unique", - "index", - "foreign_key", - "decimal_places", - "exclude", - } - pydantic_kwargs = {k: v for k, v in extra.items() if k in constraint_keys} - # Extract 'default' from the raw extra dict, not from pydantic_kwargs. - # 'default' is not in constraint_keys, so filtering would silently drop it. - _UNSET = object() - raw_default = extra.get("default", _UNSET) - if raw_default is not _UNSET: - default = raw_default - elif spec.optional: - default = None - else: - default = ... # sentinel: no default provided - - annotations[field_name] = py_type - if pydantic_kwargs or default is not ...: - if default is not ...: - pydantic_kwargs["default"] = default - class_attrs[field_name] = Field(**pydantic_kwargs) - # A constraint-less, default-less field is annotation-only. Do NOT set a - # class attribute: an Ellipsis placeholder would survive FieldInfoStripper - # (which only removes FieldInfo) and stop SQLAlchemy from mapping the - # column, so the value would silently never be inserted. - - # Build Meta inner class - Meta = _build_meta_class(entry.meta, defaults, reflect=entry.reflect) - class_attrs["Meta"] = Meta - - # Resolve bases — HttpMethod mixins from methods list - bases = _resolve_methods_bases(entry.meta) - - # type() call triggers RestEndpointMeta.__new__ automatically - return type(class_name, bases, class_attrs) - - -# ───────────────────────────────────────────────────────────────────────────── -# Public entry point -# ───────────────────────────────────────────────────────────────────────────── - - -def load_config(app_cls: type, config_path: str, **overrides: Any) -> Any: - """Parse a lightapi.yaml file and return a configured LightApi instance. - - Uses the declarative format: database.url + endpoints[].route + - endpoints[].fields + defaults + middleware. - - Kwargs override YAML-derived values (e.g. engine=..., database_url=...). - """ - import yaml - from pydantic import ValidationError - - with open(config_path) as fh: - raw: dict[str, Any] = yaml.safe_load(fh) or {} - - try: - cfg = LightAPIConfig.model_validate(raw) - except ValidationError as exc: - raise ConfigurationError( - f"Invalid lightapi.yaml — {exc.error_count()} error(s):\n{exc}" - ) from exc - - db_url = cfg.effective_database_url - middlewares: list[type] = [_resolve_name(name) for name in cfg.middleware] - - constructor_kwargs: dict[str, Any] = { - "database_url": db_url or None, - "cors_origins": cfg.cors_origins or None, - "middlewares": middlewares or None, - } - - if cfg.mode is not None: - constructor_kwargs["mode"] = cfg.mode - - # Auth/login config from YAML auth: block - if cfg.auth: - constructor_kwargs["auth_path"] = cfg.auth.auth_path - if cfg.auth.login_validator: - constructor_kwargs["login_validator"] = _resolve_callable( - cfg.auth.login_validator - ) - - constructor_kwargs.update(overrides) - - instance = app_cls(**constructor_kwargs) - - mapping: dict[str, type] = {} - for entry in cfg.endpoints: - route = entry.effective_route - endpoint_cls = _build_endpoint_class(entry, cfg.defaults) - mapping[route] = endpoint_cls - - if mapping: - instance.register(mapping) - - return instance diff --git a/pyproject.toml b/pyproject.toml index a4a937a..d54e7ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,64 +91,42 @@ testpaths = [ ] python_files = "test_*.py" asyncio_mode = "auto" +xfail_strict = true +markers = [ + "integration: needs a real Postgres/Redis, skipped unless LIGHTAPI_TEST_PG_SYNC etc. are set", +] +# Any warning the library or the tests raise fails the run; a warning a test +# expects goes through pytest.warns(). ResourceWarning keeps Python's default +# (ignored): the tests build throwaway engines they never dispose(). filterwarnings = [ + "error", + "ignore::ResourceWarning", "ignore::pytest.PytestCollectionWarning", ] [tool.ruff] line-length = 88 target-version = "py310" -exclude = [ - "tests/test_caching_example.py", - "tests/test_custom_snippet.py", - "tests/test_filtering_pagination_example.py", -] [tool.ruff.lint] select = [ - "E", - "F", - "W", - "I", + "E", "F", "W", "I", "B", "BLE", "S", "TRY", "SIM", "ASYNC", "DTZ", "PTH", + "T20", "G", "LOG", "ARG", "ERA", "RUF", "UP", "C4", "PERF", "RET", "SLF", + "PGH", "ANN401", "C901", ] +ignore = ["TRY003"] [tool.ruff.lint.per-file-ignores] -"lightapi/auth.py" = [ - "E501", -] -"lightapi/cache.py" = [ - "E501", -] -"lightapi/swagger.py" = [ - "E501", -] -"lightapi/lightapi.py" = [ - "E501", -] -"lightapi/base_endpoint.py" = [ - "E501", -] -"lightapi/core.py" = [ - "E501", -] -"lightapi/database.py" = [ - "E501", -] -"lightapi/handlers.py" = [ - "E501", -] -"lightapi/models.py" = [ - "E501", -] -"examples/*.py" = [ - "E501", -] -"tests/*.py" = [ - "E501", -] -"lightapi/session.py" = [ - "E501", -] +# Tests assert, reach into the objects they test, take fixtures they do not +# use, spell credentials out, and keep long one-line assertions. +"tests/**/*.py" = ["S101", "SLF001", "ARG", "ANN401", "S105", "S106", "S107", "E501"] +# Examples bind every interface, print, assert as smoke checks, show demo +# credentials, write `type(self)._model_class` the way the docs teach, keep the +# documented `queryset(self, request)` signature, and quote curl commands. +"examples/*.py" = ["S104", "T20", "S101", "S105", "SLF001", "ARG002", "E501"] +# The e2e apps are user code too: `type(self)._model_class` in a queryset and the +# documented `queryset(self, request)` / `process(self, request, response)` signatures. +"tools/e2e/apps/*.py" = ["SLF001", "ARG002", "T20"] [tool.mypy] python_version = "3.10" @@ -171,47 +149,10 @@ module = [ ignore_missing_imports = true [[tool.mypy.overrides]] -module = [ - "lightapi.auth", - "lightapi.cache", - "lightapi.swagger", - "lightapi.lightapi", - "lightapi.base_endpoint", - "lightapi.core", - "lightapi.database", - "lightapi.handlers", - "lightapi.models", -] -ignore_errors = true - -[[tool.mypy.overrides]] -module = [ - "lightapi.rest", - "lightapi.schema", - "lightapi.fields", - "lightapi.filters", - "lightapi.config", - "lightapi.pagination", - "lightapi.yaml_loader", - "lightapi._dict_config_loader", - "lightapi._login", - "lightapi.auth_checker", - "lightapi.auth_strategy", - "lightapi.cache_handler", - "lightapi.cache_helper", - "lightapi.cache_service", - "lightapi.crud_async", - "lightapi.crud_sync", - "lightapi.handler_factory", - "lightapi.http_dispatcher", - "lightapi.middleware_pipeline", - "lightapi.middleware_runner", - "lightapi.queryset", - "lightapi.response_wrapper", - "lightapi.route_builder", - "lightapi.session_manager", - "lightapi.table_mapping", -] +# redis-py types every reply of its sync client as "Awaitable | Any" (ResponseT), +# so get/set/ping can never type-check here; RedisCache is FROZEN and takes no +# narrowing casts. Every other module is checked. +module = ["lightapi.cache"] ignore_errors = true [dependency-groups] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 1a80e66..0000000 --- a/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -asyncio_mode = auto -env = - LIGHTAPI_JWT_SECRET=test_secret_key_for_testing - LIGHTAPI_ENV=test \ No newline at end of file diff --git a/tests/data/public_api.json b/tests/data/public_api.json new file mode 100644 index 0000000..fa17864 --- /dev/null +++ b/tests/data/public_api.json @@ -0,0 +1,1133 @@ +{ + "recorded_from": "lightapi 0.1.29 (installed package)", + "imports": [ + [ + "lightapi", + "AllowAny" + ], + [ + "lightapi", + "Authentication" + ], + [ + "lightapi", + "AuthenticationMiddleware" + ], + [ + "lightapi", + "BasicAuthentication" + ], + [ + "lightapi", + "CORSMiddleware" + ], + [ + "lightapi", + "Cache" + ], + [ + "lightapi", + "ConfigurationError" + ], + [ + "lightapi", + "Field" + ], + [ + "lightapi", + "FieldFilter" + ], + [ + "lightapi", + "Filtering" + ], + [ + "lightapi", + "HttpMethod" + ], + [ + "lightapi", + "IsAdminUser" + ], + [ + "lightapi", + "IsAuthenticated" + ], + [ + "lightapi", + "JWTAuthentication" + ], + [ + "lightapi", + "LightApi" + ], + [ + "lightapi", + "Middleware" + ], + [ + "lightapi", + "OrderingFilter" + ], + [ + "lightapi", + "Pagination" + ], + [ + "lightapi", + "RateLimiter" + ], + [ + "lightapi", + "RedisCache" + ], + [ + "lightapi", + "Response" + ], + [ + "lightapi", + "RestEndpoint" + ], + [ + "lightapi", + "SchemaFactory" + ], + [ + "lightapi", + "SearchFilter" + ], + [ + "lightapi", + "SerializationError" + ], + [ + "lightapi", + "Serializer" + ], + [ + "lightapi", + "get_async_session" + ], + [ + "lightapi", + "get_sync_session" + ], + [ + "lightapi.auth", + "AllowAny" + ], + [ + "lightapi.authentication", + "AllowAny" + ], + [ + "lightapi.authentication", + "BaseAuthentication" + ], + [ + "lightapi.authentication", + "BasePermission" + ], + [ + "lightapi.authentication", + "IsAdminUser" + ], + [ + "lightapi.authentication", + "IsAuthenticated" + ], + [ + "lightapi.authentication", + "JWTAuthentication" + ], + [ + "lightapi.cache", + "BaseCache" + ], + [ + "lightapi.config", + "Authentication" + ], + [ + "lightapi.config", + "Filtering" + ], + [ + "lightapi.config", + "Pagination" + ], + [ + "lightapi.config", + "Serializer" + ], + [ + "lightapi.core", + "AuthenticationMiddleware" + ], + [ + "lightapi.core", + "CORSMiddleware" + ], + [ + "lightapi.core", + "Middleware" + ], + [ + "lightapi.fields", + "Field" + ], + [ + "lightapi.filters", + "BaseFilter" + ], + [ + "lightapi.filters", + "FieldFilter" + ], + [ + "lightapi.filters", + "OrderingFilter" + ], + [ + "lightapi.filters", + "SearchFilter" + ], + [ + "lightapi.filters", + "_coerce_filter_value" + ], + [ + "lightapi.pagination", + "CursorPaginator" + ], + [ + "lightapi.pagination", + "PageNumberPaginator" + ], + [ + "lightapi.rate_limiter", + "RateLimiter" + ] + ], + "signatures": { + "AllowAny.has_permission": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "Authentication.__init__": [ + [ + "backend", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "permission", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "jwt_expiration", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "jwt_extra_claims", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "jwt_algorithm", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "rate_limiter", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "AuthenticationMiddleware.__init__": [ + [ + "authentication_class", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "AuthenticationMiddleware.process": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "response", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BaseAuthentication.authenticate": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BaseAuthentication.get_auth_error_response": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BaseAuthentication.validate_credentials": [ + [ + "username", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "password", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BaseCache.get": [ + [ + "key", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BaseCache.set": [ + [ + "key", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "value", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "timeout", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "BaseFilter.filter_queryset": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "queryset", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "view", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BasePermission.has_permission": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BasicAuthentication.__init__": [ + [ + "rate_limiter", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "login_validator", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "BasicAuthentication.authenticate": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BasicAuthentication.get_auth_error_response": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "BasicAuthentication.validate_credentials": [ + [ + "username", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "password", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "CORSMiddleware.__init__": [ + [ + "allow_origins", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "allow_methods", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "allow_headers", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "CORSMiddleware.process": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "response", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "Cache.__init__": [ + [ + "ttl", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "vary_on", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "CursorPaginator.paginate": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "qs", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "session", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "page_size", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "CursorPaginator.paginate_async": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "qs", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "session", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "page_size", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "CursorPaginator.wrap": [ + [ + "results", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "next_cursor", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "prev_cursor", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "Field": [ + [ + "kwargs", + "VAR_KEYWORD", + false + ] + ], + "FieldFilter.filter_queryset": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "queryset", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "view", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "Filtering.__init__": [ + [ + "backends", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "fields", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "search", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "ordering", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "IsAdminUser.has_permission": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "IsAuthenticated.has_permission": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "JWTAuthentication.__init__": [ + [ + "algorithm", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "expiration", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "rate_limiter", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "JWTAuthentication.authenticate": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "JWTAuthentication.generate_token": [ + [ + "payload", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "expiration", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "JWTAuthentication.get_auth_error_response": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "JWTAuthentication.validate_credentials": [ + [ + "username", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "password", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "LightApi.__init__": [ + [ + "engine", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "database_url", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "mode", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "cors_origins", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "middlewares", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "auth_path", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "session_manager", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "rate_limiter", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "login_validator", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "use_test_isolation", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "LightApi.build_app": [], + "LightApi.from_config": [ + [ + "config_path", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "kwargs", + "VAR_KEYWORD", + false + ] + ], + "LightApi.from_dict": [ + [ + "config", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "kwargs", + "VAR_KEYWORD", + false + ] + ], + "LightApi.register": [ + [ + "mapping", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "LightApi.run": [ + [ + "host", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "port", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "debug", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "reload", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "Middleware.process": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "response", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "OrderingFilter.filter_queryset": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "queryset", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "view", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "PageNumberPaginator.paginate": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "qs", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "session", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "page_size", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "PageNumberPaginator.paginate_async": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "qs", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "session", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "page_size", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "PageNumberPaginator.wrap": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "results", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "total", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "page", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "page_size", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "Pagination.__init__": [ + [ + "style", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "page_size", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RateLimiter.__init__": [ + [ + "requests_per_minute", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "requests_per_hour", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "requests_per_day", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RateLimiter.get_rate_limit_response": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "window", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RateLimiter.is_rate_limited": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "endpoint", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RateLimiter.reset": [], + "RedisCache.__init__": [ + [ + "host", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "port", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "db", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RedisCache.get": [ + [ + "key", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RedisCache.set": [ + [ + "key", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "value", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "timeout", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "Response.__init__": [ + [ + "content", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "status_code", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "headers", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "media_type", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "content_type", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RestEndpoint.__init__": [ + [ + "kwargs", + "VAR_KEYWORD", + false + ] + ], + "RestEndpoint._create_async": [ + [ + "data", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint._destroy_async": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "pk", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint._get_async_engine": [], + "RestEndpoint._list_async": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint._retrieve_async": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "pk", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint._update_async": [ + [ + "data", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "pk", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "partial", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "RestEndpoint.background": [ + [ + "fn", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "args", + "VAR_POSITIONAL", + false + ], + [ + "kwargs", + "VAR_KEYWORD", + false + ] + ], + "RestEndpoint.create": [ + [ + "data", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint.destroy": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "pk", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint.list": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint.retrieve": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "pk", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "RestEndpoint.update": [ + [ + "data", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "pk", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "partial", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "SchemaFactory.build": [], + "SchemaFactory.build_from_reflected_table": [ + [ + "table", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "SearchFilter.filter_queryset": [ + [ + "request", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "queryset", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "view", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "Serializer.__init__": [ + [ + "fields", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "read", + "POSITIONAL_OR_KEYWORD", + true + ], + [ + "write", + "POSITIONAL_OR_KEYWORD", + true + ] + ], + "_coerce_filter_value": [ + [ + "col", + "POSITIONAL_OR_KEYWORD", + false + ], + [ + "value", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "get_async_session": [ + [ + "engine", + "POSITIONAL_OR_KEYWORD", + false + ] + ], + "get_sync_session": [ + [ + "engine", + "POSITIONAL_OR_KEYWORD", + false + ] + ] + } +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..1193524 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for the tests that need a real Postgres and a real Redis. + +Every test here is @pytest.mark.integration and skips (not errors) when the +required environment variable is not set, so the normal test run is +unaffected: + + export LIGHTAPI_TEST_PG_SYNC="postgresql+psycopg2://postgres:test@127.0.0.1:55432/lightapi" + export LIGHTAPI_TEST_PG_ASYNC="postgresql+asyncpg://postgres:test@127.0.0.1:55432/lightapi" + export LIGHTAPI_REDIS_URL="redis://127.0.0.1:56379/0" + uv run --frozen --extra dev pytest tests/integration -q -m integration +""" + +from __future__ import annotations + +import os + +import pytest +import pytest_asyncio +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import create_async_engine + +PG_SYNC_URL = os.environ.get("LIGHTAPI_TEST_PG_SYNC") +PG_ASYNC_URL = os.environ.get("LIGHTAPI_TEST_PG_ASYNC") +REDIS_URL = os.environ.get("LIGHTAPI_REDIS_URL") + +# Each test module sets `pytestmark = requires_pg_sync` (etc.); a plain +# @pytest.mark.skipif does not carry the "integration" marker on its own, so +# it is bundled into this list. +requires_pg_sync = [ + pytest.mark.integration, + pytest.mark.skipif(not PG_SYNC_URL, reason="LIGHTAPI_TEST_PG_SYNC is not set"), +] +requires_pg_async = [ + pytest.mark.integration, + pytest.mark.skipif(not PG_ASYNC_URL, reason="LIGHTAPI_TEST_PG_ASYNC is not set"), +] +requires_redis = [ + pytest.mark.integration, + pytest.mark.skipif(not REDIS_URL, reason="LIGHTAPI_REDIS_URL is not set"), +] + + +@pytest.fixture +def pg_engine(): + """A sync Postgres engine. Endpoints map with use_test_isolation, so + concurrent runs get distinct table names (SessionManager.table_name_for) + instead of colliding on a shared 'items' table.""" + engine = create_engine(PG_SYNC_URL) + yield engine + engine.dispose() + + +@pytest_asyncio.fixture +async def pg_async_engine(): + """An async Postgres engine (asyncpg).""" + engine = create_async_engine(PG_ASYNC_URL) + yield engine + await engine.dispose() + + +@pytest.fixture +def redis_url() -> str: + return REDIS_URL diff --git a/tests/integration/test_postgres_async.py b/tests/integration/test_postgres_async.py new file mode 100644 index 0000000..adc8658 --- /dev/null +++ b/tests/integration/test_postgres_async.py @@ -0,0 +1,167 @@ +"""T3-02: async CRUD against Postgres over asyncpg, through AsyncSession.run_sync. + +The app must be BUILT before any event loop is running (see the `client` +fixture below): LightApi._create_tables() detects a running loop and creates +tables via `asyncio.run()` in a worker thread, which for asyncpg checks out a +connection bound to that thread's loop into the engine's own pool. The next +request on the real loop can then be handed that poisoned connection and +fail with "Future attached to a different loop". Reproduced identically on +the published 0.1.29, so it is a pre-existing bug (see the test plan, B19), +not a regression; this file works around it instead of triggering it. +""" + +import asyncio +from decimal import Decimal +from uuid import UUID + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import create_engine, text + +from lightapi import LightApi, RestEndpoint +from lightapi.fields import Field + +from .conftest import PG_SYNC_URL, requires_pg_async + +pytestmark = requires_pg_async + + +class PgaBook(RestEndpoint): + title: str = Field(min_length=1, max_length=50) + pages: int = Field(ge=1) + price: Decimal = Field(decimal_places=2) + rating: float | None = Field(default=None) + in_print: bool = Field(default=True) + uid: UUID | None = Field(default=None) + + +@pytest.fixture +def built_app(pg_async_engine): + """Register and create tables with no event loop running (see module docstring).""" + app = LightApi(engine=pg_async_engine, mode="async", use_test_isolation=True) + app.register({"/pgabooks": PgaBook}) + sync_engine = create_engine(PG_SYNC_URL) + app._session_manager.metadata.create_all(bind=sync_engine) + # The container persists across local runs; start each test from zero rows. + with sync_engine.connect() as conn: + conn.execute(text("TRUNCATE TABLE pgabooks RESTART IDENTITY")) + conn.commit() + sync_engine.dispose() + return app.build_app() + + +@pytest.fixture +def client(built_app): + transport = ASGITransport(app=built_app) + return AsyncClient(transport=transport, base_url="http://test") + + +async def test_insert_flush_refresh_returns_generated_columns(client): + async with client: + response = await client.post( + "/pgabooks", json={"title": "Dune", "pages": 412, "price": "9.99"} + ) + + assert response.status_code == 201 + body = response.json() + assert body["id"] is not None + assert body["created_at"] is not None + assert body["updated_at"] is not None + assert body["version"] == 1 + + +async def test_update_conflict_uses_rowcount(client): + async with client: + created = ( + await client.post( + "/pgabooks", json={"title": "Dune", "pages": 412, "price": "9.99"} + ) + ).json() + pk = created["id"] + + ok = await client.put( + f"/pgabooks/{pk}", + json={ + "title": "Dune II", + "pages": 500, + "price": "11.99", + "version": created["version"], + }, + ) + assert ok.status_code == 200 + assert ok.json()["version"] == 2 + + stale = await client.put( + f"/pgabooks/{pk}", + json={"title": "x", "pages": 1, "price": "1.00", "version": 1}, + ) + assert stale.status_code == 409 + + +async def test_delete_with_returning(client): + async with client: + created = ( + await client.post( + "/pgabooks", json={"title": "Dune", "pages": 412, "price": "9.99"} + ) + ).json() + pk = created["id"] + + deleted = await client.delete(f"/pgabooks/{pk}") + assert deleted.status_code == 204 + + again = await client.delete(f"/pgabooks/{pk}") + assert again.status_code == 404 + + +async def test_decimal_uuid_datetime_bool_round_trip(client): + uid = "6f1c1f0a-5f0e-4c3b-9d2a-0c7e8f9a1b2c" + + async with client: + created = await client.post( + "/pgabooks", + json={ + "title": "Dune", + "pages": 412, + "price": "9.99", + "rating": 4.5, + "in_print": False, + "uid": uid, + }, + ) + + assert created.status_code == 201 + body = created.json() + assert body["price"] == "9.99" + assert body["rating"] == 4.5 + assert body["in_print"] is False + assert body["uid"] == uid + + +def test_the_pool_poisoning_bug_is_reproducible(): + """Pin B19: building the app from inside a running loop breaks the next request. + + Not a regression (identical on 0.1.29); documents the trap the fixture + above avoids, so a future change to _create_tables can update this test + instead of silently losing the coverage. A plain (non-async) test: a sync + fixture cannot inject an AsyncEngine built by an async fixture into it. + """ + from sqlalchemy.ext.asyncio import create_async_engine + + from .conftest import PG_ASYNC_URL + + class PgaTrapped(RestEndpoint): + name: str = Field(min_length=1) + + async def build_and_call_inside_a_loop(): + engine = create_async_engine(PG_ASYNC_URL) + app = LightApi(engine=engine, mode="async", use_test_isolation=True) + app.register({"/pgatrapped": PgaTrapped}) + starlette_app = app.build_app() # a loop is already running here + transport = ASGITransport(app=starlette_app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/pgatrapped", json={"name": "x"}) + + response = asyncio.run(build_and_call_inside_a_loop()) + + assert response.status_code == 500 diff --git a/tests/integration/test_postgres_concurrency.py b/tests/integration/test_postgres_concurrency.py new file mode 100644 index 0000000..f006947 --- /dev/null +++ b/tests/integration/test_postgres_concurrency.py @@ -0,0 +1,106 @@ +"""T3-04: optimistic locking under real concurrent writers, sync and async. + +A real connection pool (not SQLite's StaticPool) is what run_blocking (sync) +and the async engine actually have to arbitrate concurrent writes through; +this checks the DB, not just the thread the code runs on (that part is +tests/test_sync_offload.py). + +The async app is built in a plain (sync) fixture, not inside the async test +body: see the docstring of test_postgres_async.py for why building it while +a loop is running poisons the asyncpg connection pool (B19). +""" + +import asyncio +import concurrent.futures + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import create_engine, text +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint +from lightapi.fields import Field + +from .conftest import PG_ASYNC_URL, PG_SYNC_URL + +WRITERS = 20 + + +class PgcCounter(RestEndpoint): + label: str = Field(min_length=1) + + +class PgacCounter(RestEndpoint): + """A separate class from PgcCounter: reusing one class across a sync and an + async app (both use_test_isolation=True) would leave _model_class set from + the first mapping, and the second app's register() would silently keep + operating on the first app's table instead of its own.""" + + label: str = Field(min_length=1) + + +@pytest.mark.integration +@pytest.mark.skipif(not PG_SYNC_URL, reason="LIGHTAPI_TEST_PG_SYNC is not set") +def test_sync_optimistic_lock_under_real_parallel_writers(): + engine = create_engine(PG_SYNC_URL, pool_size=WRITERS, max_overflow=0) + app = LightApi(engine=engine, use_test_isolation=True) + app.register({"/pgccounters": PgcCounter}) + client = TestClient(app.build_app()) + # The container persists across local runs; start each test from zero rows. + with engine.connect() as conn: + conn.execute(text("TRUNCATE TABLE pgccounters RESTART IDENTITY")) + conn.commit() + + created = client.post("/pgccounters", json={"label": "start"}).json() + pk, version = created["id"], created["version"] + + def attempt(_: int) -> int: + response = client.put( + f"/pgccounters/{pk}", json={"label": "updated", "version": version} + ) + return response.status_code + + with concurrent.futures.ThreadPoolExecutor(max_workers=WRITERS) as pool: + statuses = list(pool.map(attempt, range(WRITERS))) + + assert statuses.count(200) == 1 + assert statuses.count(409) == WRITERS - 1 + + final = client.get(f"/pgccounters/{pk}").json() + assert final["version"] == version + 1 + engine.dispose() + + +@pytest.fixture +def built_async_app(pg_async_engine): + app = LightApi(engine=pg_async_engine, mode="async", use_test_isolation=True) + app.register({"/pgacounters": PgacCounter}) + sync_engine = create_engine(PG_SYNC_URL) + app._session_manager.metadata.create_all(bind=sync_engine) + # PgacCounter's default table name is "pgaccounters" (class name + "s"). + with sync_engine.connect() as conn: + conn.execute(text("TRUNCATE TABLE pgaccounters RESTART IDENTITY")) + conn.commit() + sync_engine.dispose() + return app.build_app() + + +@pytest.mark.integration +@pytest.mark.skipif(not PG_ASYNC_URL, reason="LIGHTAPI_TEST_PG_ASYNC is not set") +async def test_async_optimistic_lock_under_real_concurrent_requests(built_async_app): + async with AsyncClient( + transport=ASGITransport(app=built_async_app), base_url="http://test" + ) as client: + created = (await client.post("/pgacounters", json={"label": "start"})).json() + pk, version = created["id"], created["version"] + + async def attempt(_: int) -> int: + response = await client.put( + f"/pgacounters/{pk}", json={"label": "updated", "version": version} + ) + return response.status_code + + statuses = await asyncio.gather(*(attempt(n) for n in range(WRITERS))) + + assert statuses.count(200) == 1 + assert statuses.count(409) == WRITERS - 1 diff --git a/tests/integration/test_postgres_crud.py b/tests/integration/test_postgres_crud.py new file mode 100644 index 0000000..1a97a92 --- /dev/null +++ b/tests/integration/test_postgres_crud.py @@ -0,0 +1,112 @@ +"""T3-01: sync CRUD against a real Postgres engine, real connection pool.""" + +from decimal import Decimal + +import pytest +from sqlalchemy import text +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint +from lightapi.fields import Field + +from .conftest import requires_pg_sync + +pytestmark = requires_pg_sync + + +class PgBook(RestEndpoint): + title: str = Field(min_length=1, max_length=50) + pages: int = Field(ge=1) + price: Decimal = Field(decimal_places=2) + rating: float | None = Field(default=None) + in_print: bool = Field(default=True) + + +@pytest.fixture +def client(pg_engine) -> TestClient: + app = LightApi(engine=pg_engine, use_test_isolation=True) + app.register({"/pgbooks": PgBook}) + built = TestClient(app.build_app()) + # The container is disposable per the test plan, but in practice it stays + # up across many local runs; truncate so each test starts from zero rows + # instead of accumulating leftovers from earlier invocations. + with pg_engine.connect() as conn: + conn.execute(text("TRUNCATE TABLE pgbooks RESTART IDENTITY")) + conn.commit() + return built + + +def test_create_and_list(client): + created = client.post( + "/pgbooks", json={"title": "Dune", "pages": 412, "price": "9.99"} + ) + + assert created.status_code == 201 + body = created.json() + assert body["title"] == "Dune" + assert body["price"] == "9.99" + assert body["version"] == 1 + + listed = client.get("/pgbooks") + assert listed.status_code == 200 + assert len(listed.json()["results"]) == 1 + + +def test_retrieve_not_found(client): + response = client.get("/pgbooks/9999") + + assert response.status_code == 404 + assert response.json() == {"detail": "not found"} + + +def test_update_with_optimistic_locking(client): + created = client.post( + "/pgbooks", json={"title": "Dune", "pages": 412, "price": "9.99"} + ).json() + pk = created["id"] + + updated = client.put( + f"/pgbooks/{pk}", + json={ + "title": "Dune (revised)", + "pages": 500, + "price": "12.99", + "version": created["version"], + }, + ) + assert updated.status_code == 200 + assert updated.json()["version"] == 2 + + stale = client.put( + f"/pgbooks/{pk}", + json={"title": "x", "pages": 1, "price": "1.00", "version": 1}, + ) + assert stale.status_code == 409 + assert stale.json() == {"detail": "version conflict"} + + +def test_patch_partial_and_delete(client): + created = client.post( + "/pgbooks", json={"title": "Dune", "pages": 412, "price": "9.99"} + ).json() + pk = created["id"] + + patched = client.patch( + f"/pgbooks/{pk}", json={"rating": 4.5, "version": created["version"]} + ) + assert patched.status_code == 200 + assert patched.json()["rating"] == 4.5 + assert patched.json()["title"] == "Dune" + + deleted = client.delete(f"/pgbooks/{pk}") + assert deleted.status_code == 204 + + gone = client.get(f"/pgbooks/{pk}") + assert gone.status_code == 404 + + +def test_validation_error_returns_422(client): + response = client.post("/pgbooks", json={"title": "", "pages": 0, "price": "x"}) + + assert response.status_code == 422 + assert isinstance(response.json()["detail"], list) diff --git a/tests/integration/test_postgres_reflection.py b/tests/integration/test_postgres_reflection.py new file mode 100644 index 0000000..57effa1 --- /dev/null +++ b/tests/integration/test_postgres_reflection.py @@ -0,0 +1,150 @@ +"""T3-03: reflecting a table with a realistic mix of Postgres column types. + +reflect=True does not rename the table, so this does not use +use_test_isolation (same as tests/test_reflection.py): each test creates its +own real table with a unique name instead. + +Every reflected column, nullable or not, is required in the create schema +(it may be sent as null, but must be present) -- unchanged from dev, where +build_from_reflected_table also uses `...` as the pydantic default for every +column. So a JSONB/INET/ARRAY column that falls back to Any still has to be +sent (as null) on POST; the annotation being Optional[Any] does not make the +key optional. +""" + +import uuid + +import pytest +from sqlalchemy import ( + BigInteger, + Boolean, + Column, + Date, + DateTime, + MetaData, + Numeric, + String, + Table, + Text, + Time, + text, +) +from sqlalchemy.dialects.postgresql import ARRAY, DOUBLE_PRECISION, INET, JSONB, UUID +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint + +from .conftest import requires_pg_sync + +pytestmark = requires_pg_sync + + +@pytest.fixture +def rich_table_name() -> str: + return f"pg_reflect_probe_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def rich_table(pg_engine, rich_table_name): + meta = MetaData() + table = Table( + rich_table_name, + meta, + Column("id", BigInteger, primary_key=True, autoincrement=True), + Column("label", Text, nullable=False), + Column("code", String(20), nullable=True), + Column("amount", Numeric(10, 2), nullable=True), + Column("ratio", DOUBLE_PRECISION, nullable=True), + Column("active", Boolean, nullable=True), + Column("happened_at", DateTime, nullable=True), + Column("day", Date, nullable=True), + Column("moment", Time, nullable=True), + Column("external_id", UUID, nullable=True), + Column("payload", JSONB, nullable=True), + Column("address", INET, nullable=True), + Column("tags", ARRAY(String), nullable=True), + Column("created_at", DateTime), + Column("updated_at", DateTime), + Column("version", BigInteger, server_default=text("1")), + ) + meta.create_all(pg_engine) + yield table + with pg_engine.connect() as conn: + conn.execute(text(f'DROP TABLE IF EXISTS "{rich_table_name}"')) + conn.commit() + + +FULL_ROW = { + "label": "first", + "code": "A1", + "amount": "12.50", + "ratio": 0.5, + "active": True, + "happened_at": "2026-01-01T00:00:00", + "day": "2026-01-01", + "moment": "12:30:00", + "external_id": "6f1c1f0a-5f0e-4c3b-9d2a-0c7e8f9a1b2c", + "payload": None, + "address": None, + "tags": None, +} + + +def _client(pg_engine, rich_table_name: str, route: str) -> TestClient: + # A distinct class per table name avoids the "same class name" mapper warning. + meta = type("Meta", (), {"reflect": True, "table": rich_table_name}) + endpoint_cls = type(f"PgRich_{rich_table_name}", (RestEndpoint,), {"Meta": meta}) + + app = LightApi(engine=pg_engine) + app.register({route: endpoint_cls}) + return TestClient(app.build_app()) + + +def test_known_types_are_mapped_and_validated(pg_engine, rich_table, rich_table_name): + route = f"/{rich_table_name}" + client = _client(pg_engine, rich_table_name, route) + + created = client.post(route, json=FULL_ROW) + + assert created.status_code == 201 + body = created.json() + assert body["amount"] == "12.50" # Numeric -> Decimal -> JSON string + assert body["ratio"] == "0.5" # DOUBLE_PRECISION is a Numeric too -> Decimal + assert body["active"] is True + assert body["external_id"] == FULL_ROW["external_id"] + + listed = client.get(route) + assert listed.status_code == 200 + assert len(listed.json()["results"]) == 1 + + +def test_unknown_types_fall_back_to_any_but_are_still_required( + pg_engine, rich_table, rich_table_name +): + """payload/address/tags map to Any (a WARNING is logged); the column is + still a required key in the create schema, just nullable.""" + route = f"/{rich_table_name}" + client = _client(pg_engine, rich_table_name, route) + + missing_unknown_column = {k: v for k, v in FULL_ROW.items() if k != "payload"} + rejected = client.post(route, json=missing_unknown_column) + assert rejected.status_code == 422 + + accepted = client.post(route, json=FULL_ROW) + assert accepted.status_code == 201 + assert accepted.json()["payload"] is None + + +def test_nullable_column_can_be_omitted_on_patch( + pg_engine, rich_table, rich_table_name +): + route = f"/{rich_table_name}" + client = _client(pg_engine, rich_table_name, route) + created = client.post(route, json=FULL_ROW).json() + + patched = client.patch( + f"{route}/{created['id']}", json={"version": created["version"]} + ) + + assert patched.status_code == 200 + assert patched.json()["label"] == FULL_ROW["label"] diff --git a/tests/integration/test_redis_cache.py b/tests/integration/test_redis_cache.py new file mode 100644 index 0000000..a370d36 --- /dev/null +++ b/tests/integration/test_redis_cache.py @@ -0,0 +1,154 @@ +"""T3-08 / T3-09: Meta.cache against a real Redis, and behaviour when it is down.""" + +import warnings + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import create_engine, text +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import Cache, LightApi, RestEndpoint +from lightapi.fields import Field + +from .conftest import REDIS_URL + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif(not REDIS_URL, reason="LIGHTAPI_REDIS_URL is not set"), +] + + +class RcItem(RestEndpoint): + """Used only by the sync (use_test_isolation) tests below.""" + + name: str = Field(min_length=1) + + class Meta: + cache = Cache(ttl=60) + + +class RcaItem(RestEndpoint): + """A separate class for the async (non-isolated) test: reusing RcItem's class + would leave _model_class already set, so DeclaredTable.map() would short-circuit + and never map the table into this app's own metadata.""" + + name: str = Field(min_length=1) + + class Meta: + cache = Cache(ttl=60) + + +@pytest.fixture(autouse=True) +def _fresh_cache_client(monkeypatch): + """The process-wide Redis client is memoized (lru_cache); rebuild it per test + so LIGHTAPI_REDIS_URL set by a test actually takes effect.""" + from lightapi import cache as cache_module + + monkeypatch.setenv("LIGHTAPI_REDIS_URL", REDIS_URL) + cache_module.RedisCacheBackend.shared.cache_clear() + yield + cache_module.RedisCacheBackend.shared.cache_clear() + + +@pytest.fixture +def redis_client(): + """The real Redis client the running container serves, flushed before use.""" + import redis as redis_module + + client = redis_module.from_url(REDIS_URL) + client.flushdb() + yield client + client.flushdb() + + +def _sync_client() -> TestClient: + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + app = LightApi(engine=engine, use_test_isolation=True) + app.register({"/rcitems": RcItem}) + return TestClient(app.build_app()) + + +def test_sync_get_is_served_from_real_redis_after_first_miss(redis_client): + client = _sync_client() + client.post("/rcitems", json={"name": "first"}) + + miss = client.get("/rcitems") + assert miss.status_code == 200 + assert len(redis_client.keys("lightapi:*")) == 1 + + hit = client.get("/rcitems") + assert hit.status_code == 200 + assert hit.json() == miss.json() + + +def test_sync_post_invalidates_the_real_redis_key(redis_client): + client = _sync_client() + client.post("/rcitems", json={"name": "first"}) + client.get("/rcitems") # populate the cache + assert redis_client.keys("lightapi:*") + + client.post("/rcitems", json={"name": "second"}) + + assert redis_client.keys("lightapi:*") == [] + + +async def test_async_cache_hit_and_invalidation_with_real_redis(redis_client): + engine = create_async_engine("sqlite+aiosqlite://") + # use_test_isolation avoids the process-wide global metadata, which by this + # point in a full run also holds the Postgres-typed reflection tests' + # tables (JSONB etc.) -- create_all against this sqlite engine would then + # try to build those too and fail to compile them for sqlite. + app = LightApi(engine=engine, mode="async", use_test_isolation=True) + app.register({"/rcaitems": RcaItem}) + # Create the table properly on this loop first. LightApi's own async table + # creation (triggered by build_app() below) does it via asyncio.run() in a + # worker thread, which is the same pre-existing bug as B19: harmless here + # once the table already exists (its own attempt just warns and no-ops). + async with engine.begin() as conn: + await conn.run_sync(app._session_manager.metadata.create_all) + starlette_app = app.build_app() + + async with AsyncClient( + transport=ASGITransport(app=starlette_app), base_url="http://test" + ) as client: + await client.post("/rcaitems", json={"name": "first"}) + miss = await client.get("/rcaitems") + assert redis_client.keys("lightapi:*") + + async with engine.begin() as conn: + await conn.execute(text("UPDATE rcaitems SET name = 'changed in db'")) + + hit = await client.get("/rcaitems") + assert hit.json() == miss.json() # served from cache, not the changed row + + await client.post("/rcaitems", json={"name": "second"}) + assert redis_client.keys("lightapi:*") == [] + + await engine.dispose() + + +def test_redis_unreachable_warns_but_requests_still_succeed(monkeypatch): + monkeypatch.setenv("LIGHTAPI_REDIS_URL", "redis://127.0.0.1:1/0") # nothing listens + from lightapi import cache as cache_module + + cache_module.RedisCacheBackend.shared.cache_clear() + engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + app = LightApi(engine=engine, use_test_isolation=True) + app.register({"/rcdownitems": RcItem}) + + with pytest.warns(RuntimeWarning, match="Redis"): + client = TestClient(app.build_app()) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + created = client.post("/rcdownitems", json={"name": "x"}) + listed = client.get("/rcdownitems") + + assert created.status_code == 201 + assert listed.status_code == 200 diff --git a/tests/test_async_crud.py b/tests/test_async_crud.py index 0afb1ed..ba9661d 100644 --- a/tests/test_async_crud.py +++ b/tests/test_async_crud.py @@ -11,16 +11,28 @@ from lightapi.config import Authentication -def _make_widget_app(engine): - """Build a LightApi app with a Widget endpoint.""" +class Widget(RestEndpoint): + name: str = PydanticField(min_length=1) + qty: int = PydanticField(default=0) + + class Meta: + authentication = Authentication(permission=AllowAny) + + +class Cat(RestEndpoint): + """A sync-queryset endpoint for an async engine.""" + + name: str = PydanticField(min_length=1) + + class Meta: + authentication = Authentication(permission=AllowAny) - class Widget(RestEndpoint): - name: str = PydanticField(min_length=1) - qty: int = PydanticField(default=0) + def queryset(self, request): + return sa_select(type(self)._model_class) - class Meta: - authentication = Authentication(permission=AllowAny) +def _make_widget_app(engine): + """Build a LightApi app with a Widget endpoint.""" app = LightApi(engine=engine, mode="async") app.register({"/widgets": Widget}) return app @@ -28,16 +40,6 @@ class Meta: def _make_sync_endpoint_app(engine): """Build a LightApi app with a sync-queryset endpoint on an async engine.""" - - class Cat(RestEndpoint): - name: str = PydanticField(min_length=1) - - class Meta: - authentication = Authentication(permission=AllowAny) - - def queryset(self, request): - return sa_select(type(self)._model_class) - app = LightApi(engine=engine, mode="async") app.register({"/cats": Cat}) return app @@ -52,6 +54,7 @@ async def client(): transport=ASGITransport(app=starlette_app), base_url="http://test" ) as c: yield c + await engine.dispose() @pytest_asyncio.fixture @@ -63,6 +66,7 @@ async def sync_client(): transport=ASGITransport(app=starlette_app), base_url="http://test" ) as c: yield c + await engine.dispose() # ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/tests/test_async_middleware.py b/tests/test_async_middleware.py index 92328a2..45c334f 100644 --- a/tests/test_async_middleware.py +++ b/tests/test_async_middleware.py @@ -1,5 +1,6 @@ """Tests for US5: async and sync middleware coexistence.""" +import pytest_asyncio from httpx import ASGITransport, AsyncClient from pydantic import Field as PydanticField from sqlalchemy.ext.asyncio import create_async_engine @@ -11,16 +12,21 @@ from lightapi.core import Middleware -def _make_app(middlewares, engine=None): - if engine is None: - engine = create_async_engine("sqlite+aiosqlite:///:memory:") +class Item(RestEndpoint): + name: str = PydanticField(min_length=1) - class Item(RestEndpoint): - name: str = PydanticField(min_length=1) + class Meta: + authentication = Authentication(permission=AllowAny) - class Meta: - authentication = Authentication(permission=AllowAny) +@pytest_asyncio.fixture +async def engine(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + yield engine + await engine.dispose() + + +def _make_app(middlewares, engine): app = LightApi(engine=engine, mode="async", middlewares=middlewares) app.register({"/items": Item}) return app.build_app() @@ -57,7 +63,7 @@ async def process(self, request, response): return response -async def test_async_process_is_awaited(): +async def test_async_process_is_awaited(engine): """async def process middleware is awaited and executes correctly.""" log: list = [] @@ -67,7 +73,7 @@ async def process(self, request, response): log.append("awaited") return response - app = _make_app([_MW]) + app = _make_app([_MW], engine) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as c: @@ -76,7 +82,7 @@ async def process(self, request, response): assert "awaited" in log -async def test_sync_middleware_in_async_stack(): +async def test_sync_middleware_in_async_stack(engine): """Sync process() middleware executes alongside async middleware.""" log: list = [] @@ -92,7 +98,7 @@ def process(self, request, response): log.append("s-pre") return response - app = _make_app([_AsyncMW, _SyncMW]) + app = _make_app([_AsyncMW, _SyncMW], engine) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as c: @@ -101,7 +107,7 @@ def process(self, request, response): assert "s-pre" in log -async def test_middleware_declaration_order_preserved(): +async def test_middleware_declaration_order_preserved(engine): """Pre-request middleware runs A→B→C in declaration order.""" log: list = [] @@ -123,7 +129,7 @@ async def process(self, request, response): log.append("C") return response - app = _make_app([A, B, C]) + app = _make_app([A, B, C], engine) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as c: @@ -132,9 +138,9 @@ async def process(self, request, response): assert pre_indices == sorted(pre_indices) -async def test_async_middleware_short_circuit(): +async def test_async_middleware_short_circuit(engine): """Middleware returning a Response halts the chain; endpoint is not called.""" - app = _make_app([ShortCircuitMiddleware]) + app = _make_app([ShortCircuitMiddleware], engine) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as c: diff --git a/tests/test_async_queryset.py b/tests/test_async_queryset.py index 76a10e6..4440994 100644 --- a/tests/test_async_queryset.py +++ b/tests/test_async_queryset.py @@ -11,54 +11,61 @@ from lightapi.config import Authentication -def _make_app(endpoint_cls): - engine = create_async_engine("sqlite+aiosqlite:///:memory:") +def _make_app(endpoint_cls, engine): app = LightApi(engine=engine, mode="async") app.register({"/items": endpoint_cls}) return app.build_app() -@pytest_asyncio.fixture -async def async_queryset_client(): - """App with an async def queryset that filters active=True only.""" +class ActiveItem(RestEndpoint): + """An async def queryset that filters active=True only.""" + + name: str = PydanticField(min_length=1) + active: bool = PydanticField(default=True) + + class Meta: + authentication = Authentication(permission=AllowAny) + + async def queryset(self, request): + return sa_select(type(self)._model_class).where( + type(self)._model_class.active.is_(True) + ) + + +class SyncQSItem(RestEndpoint): + """A sync queryset on an async-engine app.""" + + name: str = PydanticField(min_length=1) - class ActiveItem(RestEndpoint): - name: str = PydanticField(min_length=1) - active: bool = PydanticField(default=True) + class Meta: + authentication = Authentication(permission=AllowAny) - class Meta: - authentication = Authentication(permission=AllowAny) + def queryset(self, request): + return sa_select(type(self)._model_class) - async def queryset(self, request): - return sa_select(type(self)._model_class).where( - type(self)._model_class.active.is_(True) - ) - starlette_app = _make_app(ActiveItem) +@pytest_asyncio.fixture +async def async_queryset_client(): + """App with an async def queryset that filters active=True only.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + starlette_app = _make_app(ActiveItem, engine) async with AsyncClient( transport=ASGITransport(app=starlette_app), base_url="http://test" ) as c: yield c + await engine.dispose() @pytest_asyncio.fixture async def sync_queryset_async_app_client(): """Async-engine app with a sync queryset.""" - - class SyncQSItem(RestEndpoint): - name: str = PydanticField(min_length=1) - - class Meta: - authentication = Authentication(permission=AllowAny) - - def queryset(self, request): - return sa_select(type(self)._model_class) - - starlette_app = _make_app(SyncQSItem) + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + starlette_app = _make_app(SyncQSItem, engine) async with AsyncClient( transport=ASGITransport(app=starlette_app), base_url="http://test" ) as c: yield c + await engine.dispose() async def test_async_queryset_is_awaited(async_queryset_client): diff --git a/tests/test_async_reflection.py b/tests/test_async_reflection.py index 1111396..7de2fd6 100644 --- a/tests/test_async_reflection.py +++ b/tests/test_async_reflection.py @@ -112,14 +112,14 @@ class TestAsyncReflectFullCrud: """Full CRUD on reflected async endpoint.""" async def test_async_reflect_post_creates_row(self, async_products_engine): - class AsyncProductEndpoint(RestEndpoint): + class AsyncProductPostEndpoint(RestEndpoint): class Meta: reflect = True table = "async_products" authentication = Authentication(permission=AllowAny) app = LightApi(engine=async_products_engine, mode="async") - app.register({"/async_products": AsyncProductEndpoint}) + app.register({"/async_products": AsyncProductPostEndpoint}) starlette_app = app.build_app() async with AsyncClient( @@ -136,14 +136,14 @@ class Meta: assert float(data["price"]) == 12.50 async def test_async_reflect_put_updates_row(self, async_products_engine): - class AsyncProductEndpoint(RestEndpoint): + class AsyncProductPutEndpoint(RestEndpoint): class Meta: reflect = True table = "async_products" authentication = Authentication(permission=AllowAny) app = LightApi(engine=async_products_engine, mode="async") - app.register({"/async_products": AsyncProductEndpoint}) + app.register({"/async_products": AsyncProductPutEndpoint}) starlette_app = app.build_app() async with AsyncClient( @@ -177,14 +177,14 @@ class Meta: assert float(updated["price"]) == 14.99 async def test_async_reflect_delete_returns_204(self, async_products_engine): - class AsyncProductEndpoint(RestEndpoint): + class AsyncProductDeleteEndpoint(RestEndpoint): class Meta: reflect = True table = "async_products" authentication = Authentication(permission=AllowAny) app = LightApi(engine=async_products_engine, mode="async") - app.register({"/async_products": AsyncProductEndpoint}) + app.register({"/async_products": AsyncProductDeleteEndpoint}) starlette_app = app.build_app() async with AsyncClient( diff --git a/tests/test_async_session.py b/tests/test_async_session.py index 785ea88..380c994 100644 --- a/tests/test_async_session.py +++ b/tests/test_async_session.py @@ -55,7 +55,7 @@ def test_get_sync_session_rollback_on_exception(sync_engine): try: with get_sync_session(sync_engine) as session: session.add(_Note(body="should_rollback")) - raise ValueError("forced") + raise ValueError("forced") # noqa: TRY301 forces the rollback path except ValueError: pass @@ -83,7 +83,7 @@ async def test_get_async_session_rollback_on_exception(async_engine): try: async with get_async_session(async_engine) as session: session.add(_Note(body="async_rollback")) - raise ValueError("forced") + raise ValueError("forced") # noqa: TRY301 forces the rollback path except ValueError: pass @@ -113,7 +113,7 @@ async def test_missing_asyncio_extra_raises_config_error(monkeypatch, async_engi import importlib from lightapi.exceptions import ConfigurationError - from lightapi.lightapi import _validate_async_dependencies + from lightapi.lightapi import LightApi real_import = importlib.import_module @@ -125,7 +125,7 @@ def mock_import(name: str, *args: object, **kwargs: object) -> object: monkeypatch.setattr(importlib, "import_module", mock_import) with pytest.raises(ConfigurationError, match="sqlalchemy\\[asyncio\\]"): - _validate_async_dependencies(async_engine) + LightApi._validate_async_dependencies(async_engine) async def test_missing_dialect_driver_raises_config_error(monkeypatch, async_engine): @@ -133,7 +133,7 @@ async def test_missing_dialect_driver_raises_config_error(monkeypatch, async_eng import importlib from lightapi.exceptions import ConfigurationError - from lightapi.lightapi import _validate_async_dependencies + from lightapi.lightapi import LightApi real_import = importlib.import_module @@ -145,4 +145,4 @@ def mock_import(name: str, *args: object, **kwargs: object) -> object: monkeypatch.setattr(importlib, "import_module", mock_import) with pytest.raises(ConfigurationError, match="aiosqlite"): - _validate_async_dependencies(async_engine) + LightApi._validate_async_dependencies(async_engine) diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..e10bf23 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,913 @@ +"""Behaviour pinned or changed by the audit (docs/audit.md), one test per tracked ID.""" + +from __future__ import annotations + +import datetime +import logging +import os +import warnings +from decimal import Decimal +from unittest.mock import patch + +import pytest +import redis +from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + create_engine, + insert, +) +from sqlalchemy import select as sa_select +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from starlette.responses import JSONResponse +from starlette.testclient import TestClient + +import lightapi +from lightapi import ( + Authentication, + BasicAuthentication, + Cache, + Field, + FieldFilter, + Filtering, + IsAuthenticated, + JWTAuthentication, + LightApi, + Pagination, + RestEndpoint, +) +from lightapi.cache import RedisCacheBackend +from lightapi.config import Config +from lightapi.exceptions import ConfigurationError +from lightapi.fields import Field as LField +from lightapi.yaml_config import LightAPIConfig + +os.environ.setdefault("LIGHTAPI_JWT_SECRET", "audit-secret") + + +def _memory_engine(): + return create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + +def _client(mapping: dict[str, type], engine=None, **app_kwargs) -> TestClient: + app = LightApi(engine=engine or _memory_engine(), **app_kwargs) + app.register(mapping) + return TestClient(app.build_app()) + + +class TestB2HidingDefaults: + def test_endpoint_declared_under_future_annotations_still_maps(self): + """B2-003: RestEndpoint's own annotations must resolve under get_type_hints, + or every subclass in a module with `from __future__ import annotations` + falls back to raw strings and fails with "not in the type map".""" + + class AuditFuture(RestEndpoint): + title: str = LField(min_length=1) + + client = _client({"/future": AuditFuture}) + + assert client.post("/future", json={"title": "ok"}).status_code == 201 + + +class TestB9DispatchByName: + def test_subclass_named_rest_endpoint_is_still_mapped(self): + """B9-001: the root class is the one without bases, not the one named so.""" + + class RestEndpoint(lightapi.RestEndpoint): + title: str = LField(min_length=1) + + client = _client({"/shadow": RestEndpoint}) + + assert client.post("/shadow", json={"title": "ok"}).status_code == 201 + + +class TestB8HiddenMutation: + def test_pagination_keeps_the_style_as_given_and_still_builds_the_paginator(self): + """B8-001: no object.__setattr__ on the frozen config; the user's value stays.""" + from lightapi.pagination import CursorPaginator, PaginationStyle + + as_string = Pagination(style="cursor", page_size=5) + as_member = Pagination(style=PaginationStyle.CURSOR, page_size=5) + + assert as_string.style == "cursor" and type(as_string.style) is str + assert isinstance(as_string.paginator, CursorPaginator) + assert isinstance(as_member.paginator, CursorPaginator) + with pytest.raises(ConfigurationError, match="'offset' is invalid"): + Pagination(style="offset") + + +class TestB5PerRequestChecks: + def test_verb_override_is_resolved_at_registration(self): + """B5-001: the handler looks the hooks up once; a method assigned to the + class after register() is not consulted on every request any more.""" + + class AuditHooked(RestEndpoint): + title: str = LField(min_length=1) + + def get(self, request): + return {"hook": "registered"} + + client = _client({"/hooked": AuditHooked}) + AuditHooked.get = lambda self, request: {"hook": "late"} # type: ignore[method-assign] + + assert client.get("/hooked").json() == {"hook": "registered"} + + def test_middleware_coroutine_detection_happens_once_per_class(self): + """B5-002: the chain decides once whether each process() is awaited.""" + from lightapi.core import Middleware + from lightapi.endpoint_handler import MiddlewareChain + + class Sync(Middleware): + def process(self, request, response): + return None + + class Async(Middleware): + async def process(self, request, response): + return None + + chain = MiddlewareChain([Sync, Async]) + + assert chain._awaits == {Sync: False, Async: True} + assert chain._awaits is chain._awaits + + def test_isolated_session_managers_on_one_thread_share_the_thread_state(self): + """B5-003: the thread-local state is built by its own __init__, not lazily.""" + from lightapi.session_manager import SessionManager + + first = SessionManager(_memory_engine(), use_test_isolation=True) + second = SessionManager(_memory_engine(), use_test_isolation=True) + + assert first.metadata is second.metadata + assert first.registry is second.registry + assert first.table_name_for("audit_tbl") == "audit_tbl" + assert second.table_name_for("audit_tbl") == "audit_tbl_2" + + +class TestA4DeadCode: + def test_jwt_algorithm_env_var_is_ignored_when_config_has_the_default( + self, monkeypatch + ): + """A4-002: the env fallback was unreachable (v0.1.29 too); drop it, same result.""" + monkeypatch.setenv("LIGHTAPI_JWT_ALGORITHM", "HS512") + + assert Config().jwt_algorithm_value == "HS256" + + def test_queryset_inherited_from_a_base_class_is_used(self): + """A4-003: one getattr covers the class dict and its bases.""" + + class PublishedOnly(RestEndpoint): + _base_only = True + + def queryset(self, request): + model = type(self)._model_class + return sa_select(model).where(model.published.is_(True)) + + class AuditPost(PublishedOnly): + title: str = LField(min_length=1) + published: bool = LField(default=False) + + client = _client({"/posts": AuditPost}) + client.post("/posts", json={"title": "draft"}) + client.post("/posts", json={"title": "live", "published": True}) + + titles = [row["title"] for row in client.get("/posts").json()["results"]] + + assert titles == ["live"] + + +class _RedisDown: + """A redis client whose every call fails the way a dead server does.""" + + def _fail(self, *args, **kwargs): + raise redis.ConnectionError("Error 61 connecting to localhost:6379") + + get = setex = delete = scan_iter = ping = _fail + + +class AuditCachedItem(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + cache = Cache(ttl=30) + + +class TestB1SilentErrors: + @pytest.fixture(autouse=True) + def fresh_redis_client(self): + RedisCacheBackend.shared.cache_clear() + yield + RedisCacheBackend.shared.cache_clear() + + def test_redis_failure_is_logged_with_the_traceback_and_still_served(self, caplog): + """B1-005: the fallback stays, the operator learns why.""" + with patch("lightapi.cache.redis.from_url", return_value=_RedisDown()): + with pytest.warns(RuntimeWarning, match="not reachable"): + client = _client({"/cached": AuditCachedItem}) + caplog.clear() + with caplog.at_level(logging.WARNING, logger="lightapi.cache"): + resp = client.get("/cached") + + assert resp.status_code == 200 + records = [r for r in caplog.records if r.name == "lightapi.cache"] + assert records and all(r.exc_info for r in records) + assert "Cache read failed" in records[0].getMessage() + + def test_login_validator_exception_is_logged_with_the_traceback(self, caplog): + """B1-004: the 401 stays (documented since 0.1.24), the log carries the cause.""" + + def exploding(username: str, password: str): + raise RuntimeError("directory server is down") + + class AuditNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication(backend=JWTAuthentication) + + client = _client({"/notes": AuditNote}, login_validator=exploding) + + with caplog.at_level(logging.WARNING, logger="lightapi._login"): + resp = client.post("/auth/login", json={"username": "u", "password": "p"}) + + assert resp.status_code == 401 + assert resp.json() == {"detail": "Invalid credentials"} + record = next(r for r in caplog.records if r.name == "lightapi._login") + assert record.exc_info and "directory server is down" in str(record.exc_info[1]) + + def test_table_creation_failure_at_build_app_is_logged_with_the_traceback( + self, caplog + ): + """B1-010: build_app still returns the app; the warning explains the DDL error.""" + + class AuditPlain(RestEndpoint): + title: str = LField(min_length=1) + + app = LightApi(engine=_memory_engine()) + app.register({"/plain": AuditPlain}) + ddl_error = OperationalError("CREATE TABLE", {}, Exception("disk full")) + + with ( + patch.object(MetaData, "create_all", side_effect=ddl_error), + caplog.at_level(logging.WARNING, logger="lightapi.lightapi"), + ): + app.build_app() + + record = next(r for r in caplog.records if r.name == "lightapi.lightapi") + assert "Table creation warning" in record.getMessage() + assert record.exc_info and "disk full" in str(record.exc_info[1]) + + +class TestB4HonestTypes: + def test_authentication_without_backend_cannot_build_one(self): + """B4-005: `backend` is Optional; building from None is a configuration error.""" + with pytest.raises(ConfigurationError, match="no backend"): + Authentication().build_backend() + + def test_crud_on_an_unregistered_endpoint_names_the_missing_register_call(self): + """B4-011: `_model_class` is None until register(); the error says so.""" + + class AuditUnregistered(RestEndpoint): + title: str = LField() + + with pytest.raises(RuntimeError, match="register"): + AuditUnregistered._model_class_or_raise() + + +class TestB12Timeouts: + def test_redis_client_gets_a_read_timeout_as_well_as_a_connect_timeout(self): + """B12-001: a Redis that accepts the connection and hangs cannot hold a worker.""" + with patch("lightapi.cache.redis.from_url") as from_url: + RedisCacheBackend("redis://localhost:1")._get_client() + + from_url.assert_called_once_with( + "redis://localhost:1", socket_connect_timeout=1, socket_timeout=1 + ) + + +class TestB10TimeAndPaths: + def test_rows_inserted_outside_the_repository_get_naive_utc_timestamps(self): + """B10-001: the column defaults give the same naive UTC Repository stores, + without the deprecated utcnow().""" + + class AuditStamped(RestEndpoint): + title: str = LField() + + engine = _memory_engine() + app = LightApi(engine=engine) + app.register({"/stamped": AuditStamped}) + app.build_app() + table = AuditStamped.__table__ + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with engine.begin() as conn: + conn.execute(insert(table).values(title="outside")) + created_at = conn.execute(sa_select(table.c.created_at)).scalar_one() + + assert created_at.tzinfo is None + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + assert abs((now - created_at).total_seconds()) < 60 + + def test_from_file_accepts_a_path_object(self, tmp_path): + """B10-003: the YAML document is opened through pathlib.""" + document = tmp_path / "audit.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\nendpoints:\n' + " - route: /auditpaths\n fields:\n title: {type: str}\n" + ) + + config = LightAPIConfig.from_file(document) + + assert [entry.effective_route for entry in config.endpoints] == ["/auditpaths"] + + +class TestB14WarningsAndLogs: + def test_registration_is_logged_with_path_and_class_name(self, caplog): + """B14-001: the same message as before, built by logging instead of f-strings.""" + + class AuditLogged(RestEndpoint): + title: str = LField() + + app = LightApi(engine=_memory_engine()) + with caplog.at_level(logging.INFO, logger="lightapi.lightapi"): + app.register({"/logged": AuditLogged}) + + assert "Registering endpoint /logged -> AuditLogged" in caplog.messages + + def test_async_app_creates_its_tables_at_lifespan_startup(self): + """B14-004: lifespan replaces the deprecated on_startup handler; the tables + of an async app are still created when the server starts.""" + + class AuditLifespan(RestEndpoint): + title: str = LField() + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/lifespan": AuditLifespan}) + starlette_app = app.build_app() + + assert starlette_app.router.on_startup == [] + with TestClient(starlette_app) as client: + assert client.get("/lifespan").status_code == 200 + # Close the aiosqlite connections while their loop is still alive. + client.portal.call(engine.dispose) + + def test_registering_an_endpoint_raises_no_deprecation_warning(self): + """B14-005: the declared columns go straight into the Table; nothing copies them.""" + + class AuditFresh(RestEndpoint): + title: str = LField() + + app = LightApi(engine=_memory_engine()) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + app.register({"/fresh": AuditFresh}) + + assert AuditFresh.__table__.c.title is not None + + +class TestA10GuardAnswersWithTheBackend: + """The 401 a protected endpoint sends is the backend's get_auth_error_response(): + the documented hook, the README's JWT text, and the Basic challenge browsers need. + """ + + def test_jwt_endpoint_401_is_the_backend_reply(self): + class AuditJwtGuarded(RestEndpoint): + title: str = LField() + + class Meta: + authentication = Authentication( + backend=JWTAuthentication, permission=IsAuthenticated + ) + + client = _client({"/jwtguarded": AuditJwtGuarded}) + + response = client.get("/jwtguarded") + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication credentials invalid."} + assert response.headers["www-authenticate"] == "Bearer" + + def test_basic_endpoint_401_carries_the_basic_challenge(self): + class AuditBasicGuarded(RestEndpoint): + title: str = LField() + + class Meta: + authentication = Authentication( + backend=BasicAuthentication, permission=IsAuthenticated + ) + + client = _client({"/basicguarded": AuditBasicGuarded}) + + response = client.get("/basicguarded") + + assert response.status_code == 401 + assert response.json() == {"error": "authentication failed"} + assert response.headers["www-authenticate"] == 'Basic realm="Restricted Area"' + + def test_a_backend_that_overrides_the_hook_is_heard(self): + class Teapot(JWTAuthentication): + def get_auth_error_response(self, request): + return JSONResponse({"detail": "no tea"}, status_code=418) + + class AuditCustomGuarded(RestEndpoint): + title: str = LField() + + class Meta: + authentication = Authentication( + backend=Teapot, permission=IsAuthenticated + ) + + client = _client({"/customguarded": AuditCustomGuarded}) + + response = client.get("/customguarded") + + assert response.status_code == 418 + assert response.json() == {"detail": "no tea"} + + +class AuditFiltered(RestEndpoint): + title: str = LField() + pages: int = LField() + price: float = LField() + + class Meta: + filtering = Filtering(backends=[FieldFilter], fields=["pages", "price"]) + + +class TestB1FilterValueParsing: + """B1-008: a filter value the column type cannot parse is a 422, not a silent + empty list (SQLite) or a 500 (Postgres: invalid input syntax for type integer).""" + + def _client(self): + client = _client({"/filtered": AuditFiltered}) + client.post("/filtered", json={"title": "t", "pages": 100, "price": 9.5}) + return client + + def test_unparsable_int_filter_is_422_naming_the_parameter(self): + response = self._client().get("/filtered?pages=lots") + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "pages"] + + def test_unparsable_float_filter_is_422(self): + response = self._client().get("/filtered?price=cheap") + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "price"] + + def test_parsable_filter_values_still_filter(self): + client = self._client() + + assert len(client.get("/filtered?pages=100").json()["results"]) == 1 + assert client.get("/filtered?pages=101").json()["results"] == [] + + +def _engine_with_docs_table(): + """A pre-existing table with a nullable column, as reflection finds it.""" + engine = _memory_engine() + metadata = MetaData() + Table( + "audit_reflected_docs", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("title", String, nullable=False), + Column("notes", String, nullable=True), + Column("created_at", DateTime), + Column("updated_at", DateTime), + Column("version", Integer, nullable=False, default=1), + ) + metadata.create_all(engine) + return engine + + +class TestReflectedSchemaDefaults: + """A reflected schema honours what the table and the declared fields say about + optional values; found by e2e scenario 11 (partial reflection, declared default).""" + + def test_nullable_reflected_column_is_optional_on_post(self): + class AuditReflectedFull(RestEndpoint): + class Meta: + table = "audit_reflected_docs" + reflect = True + + client = _client( + {"/rdocs": AuditReflectedFull}, engine=_engine_with_docs_table() + ) + + created = client.post("/rdocs", json={"title": "only the title"}) + + assert created.status_code == 201, created.text + assert created.json()["notes"] is None + + def test_declared_default_applies_on_partial_reflection(self): + class AuditReflectedPartial(RestEndpoint): + notes: str = LField(default="") + + class Meta: + table = "audit_reflected_docs" + reflect = "partial" + + client = _client( + {"/pdocs": AuditReflectedPartial}, engine=_engine_with_docs_table() + ) + + created = client.post("/pdocs", json={"title": "no notes sent"}) + + assert created.status_code == 201, created.text + assert created.json()["notes"] == "" + + def test_declared_constraint_applies_on_partial_reflection(self): + class AuditReflectedStrict(RestEndpoint): + notes: str = LField(min_length=3) + + class Meta: + table = "audit_reflected_docs" + reflect = "partial" + + client = _client( + {"/sdocs": AuditReflectedStrict}, engine=_engine_with_docs_table() + ) + + assert ( + client.post("/sdocs", json={"title": "t", "notes": "ab"}).status_code == 422 + ) + assert ( + client.post("/sdocs", json={"title": "t", "notes": "abc"}).status_code + == 201 + ) + + +class TestB6AsyncFromUrl: + def test_async_mode_with_a_database_url_builds_an_async_engine(self): + """B6-001: mode='async' plus database_url used to raise at register().""" + + class AuditUrlAsync(RestEndpoint): + title: str = LField() + + app = LightApi(database_url="sqlite+aiosqlite:///:memory:", mode="async") + app.register({"/urlasync": AuditUrlAsync}) + + with TestClient(app.build_app()) as client: + assert client.post("/urlasync", json={"title": "t"}).status_code == 201 + assert client.get("/urlasync").json()["results"][0]["title"] == "t" + client.portal.call(app._engine.dispose) + + def test_async_mode_with_a_sync_driver_url_is_a_configuration_error(self): + with pytest.raises(ConfigurationError, match="async driver"): + LightApi(database_url="sqlite:///:memory:", mode="async") + + def test_from_config_serves_an_async_document(self, tmp_path): + document = tmp_path / "async.yaml" + document.write_text( + 'database:\n url: "sqlite+aiosqlite:///:memory:"\nmode: async\nendpoints:\n' + " - route: /asyncdocs\n fields:\n title: {type: str}\n" + ) + + app = LightApi.from_config(str(document)) + + with TestClient(app.build_app()) as client: + assert client.post("/asyncdocs", json={"title": "y"}).status_code == 201 + client.portal.call(app._engine.dispose) + + +class AuditPriced(RestEndpoint): + price: Decimal = Field(decimal_places=2) + + class Meta: + table = "audit_priced" + + +class TestB6DecimalPlaces: + """B6-006: `Field(decimal_places=N)` reached neither pydantic nor the DDL + (`Numeric(scale=N)` without a precision renders as plain NUMERIC), so a value + with more places was stored as sent. e2e scenario 17 stored "1.999" on both DBs.""" + + def test_more_decimal_places_than_declared_is_422(self): + with _client({"/priced": AuditPriced}) as client: + response = client.post("/priced", json={"price": "1.999"}) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["price"] + + def test_declared_decimal_places_are_accepted_and_kept(self): + with _client({"/priced": AuditPriced}) as client: + response = client.post("/priced", json={"price": "12.50"}) + + assert response.status_code == 201 + assert response.json()["price"] == "12.50" + + +class AuditPatched(RestEndpoint): + name: str = Field(min_length=3) + price: Decimal = Field(decimal_places=2) + + class Meta: + table = "audit_patched" + + +class TestReviewPatchKeepsConstraints: + """B2-013: the PATCH schema rebuilt every field as `Optional[annotation]` and + dropped its FieldInfo, so PATCH skipped min_length, decimal_places and every + other constraint PUT enforces (review finding 6 on the e2e branch).""" + + def _seeded(self): + client = _client({"/patched": AuditPatched}) + client.__enter__() + row = client.post("/patched", json={"name": "abc", "price": "1.00"}).json() + return client, row + + def test_patch_enforces_decimal_places(self): + client, row = self._seeded() + with client: + response = client.patch( + f"/patched/{row['id']}", json={"price": "1.999", "version": 1} + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["price"] + + def test_patch_enforces_min_length(self): + client, row = self._seeded() + with client: + response = client.patch( + f"/patched/{row['id']}", json={"name": "ab", "version": 1} + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["name"] + + def test_patch_with_a_valid_subset_still_works(self): + client, row = self._seeded() + with client: + response = client.patch( + f"/patched/{row['id']}", json={"name": "abcd", "version": 1} + ) + + assert response.status_code == 200 + assert response.json()["name"] == "abcd" + assert response.json()["price"] == "1.00" + + +class TestReviewDecimalPlacesNeedsDecimal: + """B6-007: `decimal_places` on a non-Decimal field used to be ignored and, once + forwarded to pydantic (B6-006), made every write a 500 (`TypeError: Unable to + apply constraint`). It is refused when the class is defined, naming the field + (review finding 2 on the e2e branch).""" + + def test_decimal_places_on_a_float_field_is_a_configuration_error(self): + with pytest.raises(ConfigurationError, match=r"decimal_places.*ratio"): + + class AuditBadPlaces(RestEndpoint): + ratio: float = Field(decimal_places=2) + + class Meta: + table = "audit_bad_places" + + def test_decimal_places_on_a_yaml_int_field_is_a_configuration_error( + self, tmp_path + ): + document = tmp_path / "bad.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\nendpoints:\n - route: /x\n' + " fields:\n count: {type: int, decimal_places: 2}\n" + ) + + with pytest.raises(ConfigurationError, match=r"decimal_places.*count"): + LightAPIConfig.from_file(document).build_app(LightApi) + + +class TestReviewSecretsStayOutOfErrors: + """B1-020: two error messages echoed the database URL with its password: the + async-driver ConfigurationError (B6-001) and the YAML ValidationError text under + extra="forbid" (B2-012), which prints `input_value=` (review finding 3).""" + + # Built from parts so no credential-shaped literal sits in the source. + CANARY = "canary-value" + URL = "postgresql://app:" + CANARY + "@db/app" + + def test_async_driver_error_hides_the_password(self): + with pytest.raises(ConfigurationError) as info: + LightApi(database_url=self.URL, mode="async") + + assert self.CANARY not in str(info.value) + assert "postgresql://app:***@db/app" in str(info.value) + + def test_yaml_typo_error_hides_the_password_and_names_the_key(self, tmp_path): + document = tmp_path / "bad.yaml" + document.write_text( + f'databse:\n url: "{self.URL}"\nendpoints:\n' + " - route: /x\n fields:\n title: {type: str}\n" + ) + + with pytest.raises(ConfigurationError, match="databse") as info: + LightAPIConfig.from_file(document) + + assert self.CANARY not in str(info.value) + + +class TestReviewFieldSpecUnknownKey: + """B2-014: a typo under `fields.` (`min_lenght`) was dropped by FieldSpec's + extra="allow", so the constraint silently vanished while the configuration + guide promises a ConfigurationError (review finding 7).""" + + def test_unknown_constraint_key_is_rejected(self, tmp_path): + document = tmp_path / "bad.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\nendpoints:\n - route: /x\n' + " fields:\n title: {type: str, min_lenght: 3}\n" + ) + + with pytest.raises(ConfigurationError, match="min_lenght"): + LightAPIConfig.from_file(document) + + def test_known_constraint_keys_and_default_still_load(self, tmp_path): + document = tmp_path / "ok.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\nendpoints:\n - route: /x\n' + " fields:\n title: {type: str, min_length: 3, default: abc}\n" + ) + + spec = LightAPIConfig.from_file(document).endpoints[0].fields["title"] + + assert spec.field_info() is not None + + +class AuditAsyncUrl(RestEndpoint): + title: str = Field(min_length=1) + + class Meta: + table = "audit_async_url" + + +class TestReviewAsyncFromUrlAutoDetect: + """B6-004: README says the mode is auto-detected when omitted, but a database URL + with an async driver and no `mode="async"` built a sync Engine (review minor 8).""" + + def test_async_url_without_mode_serves(self): + app = LightApi(database_url="sqlite+aiosqlite:///:memory:") + app.register({"/autodetected": AuditAsyncUrl}) + with TestClient(app.build_app()) as client: + response = client.post("/autodetected", json={"title": "t"}) + + assert response.status_code == 201 + client.portal.call(app._engine.dispose) + + def test_yaml_async_url_without_mode_serves(self, tmp_path): + document = tmp_path / "async.yaml" + document.write_text( + 'database:\n url: "sqlite+aiosqlite:///:memory:"\nendpoints:\n' + " - route: /autodetected\n fields:\n title: {type: str}\n" + ) + app = LightAPIConfig.from_file(document).build_app(LightApi) + with TestClient(app.build_app()) as client: + response = client.post("/autodetected", json={"title": "t"}) + + assert response.status_code == 201 + client.portal.call(app._engine.dispose) + + +class TestB6AsyncBasicValidatorRefused: + """B6-005: an `async def validate_credentials` on a BasicAuthentication subclass + was called without await by the guard, the coroutine was truthy, and ANY Basic + credentials passed. The login route awaits it, the guard cannot: refused at + register() with a ConfigurationError naming the method.""" + + def test_async_validator_on_basic_is_refused_at_register(self): + class AuditAsyncBasic(BasicAuthentication): + async def validate_credentials(self, username, password): + return {"username": username} + + class AuditBasicGuardedAsync(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + table = "audit_basic_guarded_async" + authentication = Authentication( + backend=AuditAsyncBasic, permission=IsAuthenticated + ) + + app = LightApi(engine=_memory_engine()) + + with pytest.raises(ConfigurationError, match="validate_credentials"): + app.register({"/asyncbasic": AuditBasicGuardedAsync}) + + +class TestReviewFilterIntegerRange: + """B1-021: an integer filter value beyond 64 bits reached the database and was a + 500 (OverflowError on SQLite), the same class as B1-008 (review minor 9).""" + + def test_integer_filter_beyond_64_bits_is_422(self): + with _client({"/filtered": AuditFiltered}) as client: + response = client.get("/filtered?pages=99999999999999999999") + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["query", "pages"] + + +class TestReviewAsyncReflectionPool: + """B7-004: async reflection at register() ran on a throwaway loop and left that + connection in the pool, bound to a loop that no longer exists; asyncpg refuses + it on the first request that draws it (same mechanism as B7-003; review minor 11). + """ + + def test_async_reflection_leaves_no_connection_in_the_pool(self, tmp_path): + path = tmp_path / "reflected.sqlite" + metadata = MetaData() + Table( + "audit_async_reflected", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("title", String, nullable=False), + Column("created_at", DateTime), + Column("updated_at", DateTime), + Column("version", Integer, nullable=False, default=1), + ) + sync_engine = create_engine(f"sqlite:///{path}") + metadata.create_all(sync_engine) + sync_engine.dispose() + engine = create_async_engine(f"sqlite+aiosqlite:///{path}") + + class AuditAsyncReflected(RestEndpoint): + class Meta: + table = "audit_async_reflected" + reflect = True + + app = LightApi(engine=engine, mode="async") + app.register({"/asyncreflected": AuditAsyncReflected}) + + assert engine.pool.checkedin() == 0 + with TestClient(app.build_app()) as client: + assert client.get("/asyncreflected").status_code == 200 + client.portal.call(engine.dispose) + + +class TestYamlUnknownKeys: + """A key the YAML format does not know is a ConfigurationError naming it, not a + silently ignored block; found by e2e scenario 12 (`defaults.filtering`).""" + + def test_unknown_key_under_defaults_is_rejected(self, tmp_path): + document = tmp_path / "bad.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\ndefaults:\n filtering:\n' + " fields: [genre]\nendpoints:\n - route: /x\n fields:\n" + " title: {type: str}\n" + ) + + with pytest.raises(ConfigurationError, match="filtering"): + LightAPIConfig.from_file(document) + + def test_unknown_key_under_meta_is_rejected(self, tmp_path): + document = tmp_path / "bad.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\nendpoints:\n - route: /x\n' + " fields:\n title: {type: str}\n meta:\n paginate: true\n" + ) + + with pytest.raises(ConfigurationError, match="paginate"): + LightAPIConfig.from_file(document) + + def test_unknown_top_level_key_is_rejected(self, tmp_path): + document = tmp_path / "bad.yaml" + document.write_text( + 'database:\n url: "sqlite:///:memory:"\nmiddlewares: []\nendpoints:\n' + " - route: /x\n fields:\n title: {type: str}\n" + ) + + with pytest.raises(ConfigurationError, match="middlewares"): + LightAPIConfig.from_file(document) + + +class TestB19LifespanLoop: + def test_lifespan_creates_tables_on_the_serving_loop_not_a_worker_thread(self): + """B19/B7-003: tables created on a worker loop poison an asyncpg pool; the + lifespan must await create_all on the loop that serves the requests.""" + + class AuditLoopBound(RestEndpoint): + title: str = LField() + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/loopbound": AuditLoopBound}) + starlette_app = app.build_app() + + with ( + patch( + "lightapi.lightapi.concurrent.futures.ThreadPoolExecutor", + side_effect=AssertionError("table creation left the serving loop"), + ), + TestClient(starlette_app) as client, + ): + assert client.post("/loopbound", json={"title": "t"}).status_code == 201 + assert client.get("/loopbound").json()["results"][0]["title"] == "t" + client.portal.call(engine.dispose) diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py index 3e6513e..b6e3664 100644 --- a/tests/test_background_tasks.py +++ b/tests/test_background_tasks.py @@ -1,6 +1,7 @@ """Tests for US4: self.background() fire-and-forget tasks.""" import asyncio +import json import pytest import pytest_asyncio @@ -12,76 +13,100 @@ from lightapi.auth import AllowAny from lightapi.config import Authentication +# Every background notification lands here; each fixture starts it empty. +_TRACKER: list[int] = [] -def _build_app(tracker: list, use_async_fn: bool = False, multi: bool = False): - engine = create_async_engine("sqlite+aiosqlite:///:memory:") - if use_async_fn: +def _notify(item_id: int) -> None: + _TRACKER.append(item_id) - async def notify(item_id: int) -> None: - tracker.append(item_id) - else: - def notify(item_id: int) -> None: # type: ignore[misc] - tracker.append(item_id) +async def _notify_async(item_id: int) -> None: + _TRACKER.append(item_id) - class BgItem(RestEndpoint): - name: str = PydanticField(min_length=1) - class Meta: - authentication = Authentication(permission=AllowAny) +async def _created_id(endpoint: RestEndpoint, request) -> tuple: + """(response, id) of the row the POST body creates.""" + item = await endpoint._create_async(json.loads(await request.body())) + return item, json.loads(item.body)["id"] - async def post(self, request): - item = await self._create_async(await _read_json(request)) - import json - body = json.loads(item.body) - if multi: - self.background(notify, body["id"]) - self.background(notify, body["id"] + 100) - else: - self.background(notify, body["id"]) - return item +class BgItem(RestEndpoint): + name: str = PydanticField(min_length=1) - app = LightApi(engine=engine, mode="async") - app.register({"/items": BgItem}) - return app.build_app() + class Meta: + authentication = Authentication(permission=AllowAny) + + async def post(self, request): + item, item_id = await _created_id(self, request) + self.background(_notify, item_id) + return item + + +class BgAsyncFnItem(RestEndpoint): + name: str = PydanticField(min_length=1) + + class Meta: + authentication = Authentication(permission=AllowAny) + async def post(self, request): + item, item_id = await _created_id(self, request) + self.background(_notify_async, item_id) + return item -async def _read_json(request): - import json - return json.loads(await request.body()) +class BgMultiItem(RestEndpoint): + name: str = PydanticField(min_length=1) + + class Meta: + authentication = Authentication(permission=AllowAny) + + async def post(self, request): + item, item_id = await _created_id(self, request) + self.background(_notify, item_id) + self.background(_notify, item_id + 100) + return item + + +def _build_app(endpoint_cls: type[RestEndpoint], engine): + app = LightApi(engine=engine, mode="async") + app.register({"/items": endpoint_cls}) + return app.build_app() @pytest_asyncio.fixture async def bg_client(): - tracker: list = [] - starlette_app = _build_app(tracker) + _TRACKER.clear() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with AsyncClient( - transport=ASGITransport(app=starlette_app), base_url="http://test" + transport=ASGITransport(app=_build_app(BgItem, engine)), base_url="http://test" ) as c: - yield c, tracker + yield c, _TRACKER + await engine.dispose() @pytest_asyncio.fixture async def async_fn_client(): - tracker: list = [] - starlette_app = _build_app(tracker, use_async_fn=True) + _TRACKER.clear() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with AsyncClient( - transport=ASGITransport(app=starlette_app), base_url="http://test" + transport=ASGITransport(app=_build_app(BgAsyncFnItem, engine)), + base_url="http://test", ) as c: - yield c, tracker + yield c, _TRACKER + await engine.dispose() @pytest_asyncio.fixture async def multi_client(): - tracker: list = [] - starlette_app = _build_app(tracker, multi=True) + _TRACKER.clear() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with AsyncClient( - transport=ASGITransport(app=starlette_app), base_url="http://test" + transport=ASGITransport(app=_build_app(BgMultiItem, engine)), + base_url="http://test", ) as c: - yield c, tracker + yield c, _TRACKER + await engine.dispose() async def test_background_fn_runs_after_response(bg_client): diff --git a/tests/test_cache_async.py b/tests/test_cache_async.py new file mode 100644 index 0000000..f587efb --- /dev/null +++ b/tests/test_cache_async.py @@ -0,0 +1,78 @@ +"""Meta.cache must also work when the app runs on an AsyncEngine.""" + +from unittest.mock import patch + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import create_async_engine + +from lightapi import Cache, LightApi, RestEndpoint +from lightapi.fields import Field as LField + +TTL = 60 + + +class AsyncCachedEndpoint(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + cache = Cache(ttl=TTL) + + +@pytest_asyncio.fixture +async def client(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/asynccached": AsyncCachedEndpoint}) + async with AsyncClient( + transport=ASGITransport(app=app.build_app()), base_url="http://test" + ) as c: + yield c + await engine.dispose() + + +async def test_async_get_list_cache_hit_returns_cached_body(client): + cached_body = {"results": [{"id": 1, "name": "from-cache"}]} + + with ( + patch("lightapi.cache.RedisCacheBackend.get", return_value=cached_body), + patch("lightapi.cache.RedisCacheBackend.set") as mock_set, + ): + response = await client.get("/asynccached") + + assert response.status_code == 200 + assert response.json() == cached_body + mock_set.assert_not_called() + + +async def test_async_get_list_cache_miss_stores_response_with_ttl(client): + with ( + patch("lightapi.cache.RedisCacheBackend.get", return_value=None), + patch("lightapi.cache.RedisCacheBackend.set") as mock_set, + ): + response = await client.get("/asynccached") + + assert response.status_code == 200 + key, body, ttl = mock_set.call_args.args + assert "AsyncCachedEndpoint" in key + assert body == {"results": []} + assert ttl == TTL + + +async def test_async_post_invalidates_the_endpoint_prefix(client): + with patch("lightapi.cache.RedisCacheBackend.invalidate_prefix") as mock_invalidate: + response = await client.post("/asynccached", json={"name": "fresh"}) + + assert response.status_code == 201 + mock_invalidate.assert_called_once_with("lightapi:AsyncCachedEndpoint:") + + +async def test_async_get_does_not_invalidate(client): + with ( + patch("lightapi.cache.RedisCacheBackend.get", return_value=None), + patch("lightapi.cache.RedisCacheBackend.set"), + patch("lightapi.cache.RedisCacheBackend.invalidate_prefix") as mock_inv, + ): + await client.get("/asynccached") + + mock_inv.assert_not_called() diff --git a/tests/test_cache_backend_lazy.py b/tests/test_cache_backend_lazy.py new file mode 100644 index 0000000..73fb49b --- /dev/null +++ b/tests/test_cache_backend_lazy.py @@ -0,0 +1,39 @@ +"""The process-wide Redis client is created on first use, not at import time.""" + +from unittest.mock import patch + +import pytest + +from lightapi import cache +from lightapi.cache import RedisCacheBackend + +LATE_URL = "redis://configured-after-import:6379/3" + + +@pytest.fixture(autouse=True) +def fresh_default_backend(): + RedisCacheBackend.shared.cache_clear() + yield + RedisCacheBackend.shared.cache_clear() + + +def test_redis_url_set_after_import_is_used_on_first_cache_call(monkeypatch): + monkeypatch.setenv("LIGHTAPI_REDIS_URL", LATE_URL) + + with patch("lightapi.cache.redis.from_url") as from_url: + from_url.return_value.get.return_value = None + RedisCacheBackend.shared().get("any-key") + + assert from_url.call_args.args[0] == LATE_URL + + +def test_cache_calls_share_one_client(monkeypatch): + monkeypatch.setenv("LIGHTAPI_REDIS_URL", LATE_URL) + + with patch("lightapi.cache.redis.from_url") as from_url: + from_url.return_value.get.return_value = None + RedisCacheBackend.shared().get("first") + RedisCacheBackend.shared().set("second", {"n": 1}, 30) + cache.invalidate_cache_prefix("third") + + assert from_url.call_count == 1 diff --git a/tests/test_cache_v2.py b/tests/test_cache_v2.py index 5437179..3ec0158 100644 --- a/tests/test_cache_v2.py +++ b/tests/test_cache_v2.py @@ -3,14 +3,25 @@ from unittest.mock import patch import pytest +import redis from sqlalchemy import create_engine from sqlalchemy.pool import StaticPool from starlette.testclient import TestClient from lightapi import Cache, LightApi, RestEndpoint +from lightapi.cache import RedisCacheBackend from lightapi.fields import Field as LField +class _RedisDown: + """A redis client whose every call fails the way a dead server does.""" + + def _fail(self, *args, **kwargs): + raise redis.ConnectionError("Error 61 connecting to localhost:6379") + + get = setex = delete = scan_iter = ping = _fail + + class CachedEndpoint(RestEndpoint): name: str = LField(min_length=1) @@ -40,33 +51,35 @@ def client(): class TestCacheGetList: def test_cache_get_list_returns_cached_on_second_request(self, client): """First GET hits DB, second GET returns cached (when get_cached returns data).""" - with patch("lightapi.cache_helper.get_cached") as mock_get: - with patch("lightapi.cache_helper.set_cached") as mock_set: - mock_get.return_value = None # First call: cache miss - resp1 = client.get("/cached") - assert resp1.status_code == 200 - mock_set.assert_called_once() - - mock_get.return_value = {"results": [{"id": 1, "name": "cached"}]} - mock_get.reset_mock() - mock_set.reset_mock() - resp2 = client.get("/cached") - assert resp2.status_code == 200 - assert resp2.json()["results"][0]["name"] == "cached" - mock_get.assert_called() - mock_set.assert_not_called() + with ( + patch("lightapi.cache.RedisCacheBackend.get") as mock_get, + patch("lightapi.cache.RedisCacheBackend.set") as mock_set, + ): + mock_get.return_value = None # First call: cache miss + resp1 = client.get("/cached") + assert resp1.status_code == 200 + mock_set.assert_called_once() + + mock_get.return_value = {"results": [{"id": 1, "name": "cached"}]} + mock_get.reset_mock() + mock_set.reset_mock() + resp2 = client.get("/cached") + assert resp2.status_code == 200 + assert resp2.json()["results"][0]["name"] == "cached" + mock_get.assert_called() + mock_set.assert_not_called() class TestCacheInvalidation: def test_cache_post_invalidates(self, client): """POST triggers cache invalidation.""" - with patch("lightapi.cache_helper.invalidate_cache_prefix") as mock_inv: + with patch("lightapi.cache.RedisCacheBackend.invalidate_prefix") as mock_inv: client.post("/cached", json={"name": "new"}) mock_inv.assert_called() def test_cache_put_invalidates(self, client): """PUT triggers cache invalidation.""" - with patch("lightapi.cache_helper.invalidate_cache_prefix") as mock_inv: + with patch("lightapi.cache.RedisCacheBackend.invalidate_prefix") as mock_inv: post_resp = client.post("/cached", json={"name": "item"}) item_id = post_resp.json()["id"] version = post_resp.json()["version"] @@ -78,7 +91,7 @@ def test_cache_put_invalidates(self, client): def test_cache_delete_invalidates(self, client): """DELETE triggers cache invalidation.""" - with patch("lightapi.cache_helper.invalidate_cache_prefix") as mock_inv: + with patch("lightapi.cache.RedisCacheBackend.invalidate_prefix") as mock_inv: post_resp = client.post("/cached", json={"name": "to_delete"}) item_id = post_resp.json()["id"] client.delete(f"/cached/{item_id}") @@ -88,24 +101,29 @@ def test_cache_delete_invalidates(self, client): class TestCacheRedisUnreachable: def test_cache_redis_unreachable_startup_warning(self): """When Redis unreachable and cache configured, RuntimeWarning emitted at startup.""" - with patch("lightapi.cache._ping_redis", return_value=False): - with pytest.warns(RuntimeWarning, match="Redis|Cache"): - engine = create_engine( - "sqlite:///:memory:", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) - app = LightApi(engine=engine) - app.register({"/cached": CachedEndpoint}) - app.build_app() + with ( + patch("lightapi.cache.RedisCacheBackend.ping", return_value=False), + pytest.warns(RuntimeWarning, match="Redis|Cache"), + ): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + app = LightApi(engine=engine) + app.register({"/cached": CachedEndpoint}) + app.build_app() def test_cache_redis_unreachable_mid_request_serves_db(self, client): - """When get_cached raises/fails, GET still returns 200 from DB.""" - with patch("lightapi.cache_helper.get_cached", side_effect=Exception("Redis down")): - with patch("lightapi.cache_helper.set_cached"): + """When the Redis client raises, GET still returns 200 from DB.""" + RedisCacheBackend.shared.cache_clear() + try: + with patch("lightapi.cache.redis.from_url", return_value=_RedisDown()): resp = client.get("/cached") assert resp.status_code == 200 assert "results" in resp.json() + finally: + RedisCacheBackend.shared.cache_clear() class TestCacheVaryOn: @@ -121,11 +139,13 @@ def test_cache_vary_on_query_params_uses_different_keys(self): c = TestClient(app.build_app()) c.post("/cached_vary", json={"label": "x"}) - with patch("lightapi.cache_helper.get_cached") as mock_get: - with patch("lightapi.cache_helper.set_cached") as mock_set: - mock_get.return_value = None - c.get("/cached_vary?page=1") - c.get("/cached_vary?page=2") - keys = [c[0][0] for c in mock_set.call_args_list] - if len(keys) >= 2: - assert keys[0] != keys[1] + with ( + patch("lightapi.cache.RedisCacheBackend.get") as mock_get, + patch("lightapi.cache.RedisCacheBackend.set") as mock_set, + ): + mock_get.return_value = None + c.get("/cached_vary?page=1") + c.get("/cached_vary?page=2") + keys = [c[0][0] for c in mock_set.call_args_list] + assert len(keys) == 2 + assert keys[0] != keys[1] diff --git a/tests/test_cli_serve.py b/tests/test_cli_serve.py new file mode 100644 index 0000000..47e3b52 --- /dev/null +++ b/tests/test_cli_serve.py @@ -0,0 +1,145 @@ +"""T4-01, T4-02, T4-03: the `lightapi serve` command and `python -m lightapi`, +run as real subprocesses against the container's example config.""" + +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +import pytest + +EXAMPLE_CONFIG = "docker/lightapi.example.yaml" + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_up(base_url: str, timeout: float = 15.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + health = f"{base_url}/healthz" + urllib.request.urlopen(health, timeout=1).read() # noqa: S310 test server + except Exception: # noqa: BLE001,PERF203 poll until the server answers + time.sleep(0.2) + else: + return True + return False + + +def _get(url: str) -> tuple[int, bytes]: + try: + with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 test server + return resp.status, resp.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + +@pytest.fixture +def real_server(tmp_path): + """Starts a subprocess with the given argv and env, tears it down after.""" + procs: list[subprocess.Popen] = [] + + def start(argv: list[str], env_overrides: dict) -> tuple[subprocess.Popen, str]: + port = _free_port() + env = { + **os.environ, + "LIGHTAPI_HOST": "127.0.0.1", + "LIGHTAPI_PORT": str(port), + "DATABASE_URL": f"sqlite:///{tmp_path}/t401.db", + **env_overrides, + } + proc = subprocess.Popen( # noqa: S603 the CLI under test, fixed argv + argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + procs.append(proc) + return proc, f"http://127.0.0.1:{port}" + + yield start + + for proc in procs: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +def test_lightapi_serve_console_script(real_server): + proc, base = real_server( + [sys.executable, "-m", "lightapi", "serve"], + {"LIGHTAPI_CONFIG": EXAMPLE_CONFIG}, + ) + + up = _wait_up(base) + assert up, proc.stdout.read() if proc.poll() is not None else "server timed out" + + healthz_status, _ = _get(f"{base}/healthz") + books_status, books_body = _get(f"{base}/books") + assert healthz_status == 200 + assert books_status == 200 + assert b"results" in books_body + + +def test_python_dash_m_lightapi_defaults_to_serve(real_server): + proc, base = real_server( + [sys.executable, "-m", "lightapi"], {"LIGHTAPI_CONFIG": EXAMPLE_CONFIG} + ) + + up = _wait_up(base) + assert up, proc.stdout.read() if proc.poll() is not None else "server timed out" + status, _ = _get(f"{base}/healthz") + assert status == 200 + + +def test_missing_lightapi_config_exits_nonzero_with_a_clear_message(): + env = {k: v for k, v in os.environ.items() if k != "LIGHTAPI_CONFIG"} + + result = subprocess.run( # noqa: S603 the CLI under test, fixed argv + [sys.executable, "-m", "lightapi", "serve"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode != 0 + assert "LIGHTAPI_CONFIG" in (result.stdout + result.stderr) + + +def test_invalid_port_exits_nonzero_with_a_clear_message(): + env = { + **os.environ, + "LIGHTAPI_CONFIG": EXAMPLE_CONFIG, + "LIGHTAPI_PORT": "not-a-number", + "DATABASE_URL": "sqlite://", + } + + result = subprocess.run( # noqa: S603 the CLI under test, fixed argv + [sys.executable, "-m", "lightapi", "serve"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode != 0 + assert "LIGHTAPI_PORT" in (result.stdout + result.stderr) + + +def test_unknown_subcommand_exits_nonzero(): + result = subprocess.run( # noqa: S603 the CLI under test, fixed argv + [sys.executable, "-m", "lightapi", "dance"], + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode != 0 + assert "dance" in (result.stdout + result.stderr) diff --git a/tests/test_constraints.py b/tests/test_constraints.py new file mode 100644 index 0000000..bb4b861 --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,135 @@ +"""A database constraint violation is a 409, not a 500, on every write path.""" + +import logging + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint +from lightapi.fields import Field as LField + + +class Account(RestEndpoint): + email: str = LField(min_length=1, unique=True) + name: str = LField(default="") + + class Meta: + table = "constraint_accounts" + + +class AsyncAccount(RestEndpoint): + email: str = LField(min_length=1, unique=True) + + class Meta: + table = "constraint_async_accounts" + + +def _memory_engine(): + return create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + +@pytest.fixture +def client(): + app = LightApi(engine=_memory_engine()) + app.register({"/accounts": Account}) + with TestClient(app.build_app()) as c: + yield c + + +def test_duplicate_unique_value_on_post_answers_409(client): + assert client.post("/accounts", json={"email": "a@x"}).status_code == 201 + + duplicate = client.post("/accounts", json={"email": "a@x"}) + + assert duplicate.status_code == 409 + assert duplicate.json() == {"detail": "constraint violation"} + assert len(client.get("/accounts").json()["results"]) == 1 + + +def test_update_to_duplicate_unique_value_answers_409(client): + client.post("/accounts", json={"email": "a@x"}) + second = client.post("/accounts", json={"email": "b@x"}).json() + + collide = client.put( + f"/accounts/{second['id']}", json={"email": "a@x", "name": "", "version": 1} + ) + + assert collide.status_code == 409 + assert collide.json() == {"detail": "constraint violation"} + assert client.get(f"/accounts/{second['id']}").json()["email"] == "b@x" + + +def test_the_session_is_usable_after_the_409(client): + client.post("/accounts", json={"email": "a@x"}) + client.post("/accounts", json={"email": "a@x"}) + + assert client.post("/accounts", json={"email": "c@x"}).status_code == 201 + + +class Shelf(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + table = "constraint_shelves" + + +class Volume(RestEndpoint): + title: str = LField(min_length=1) + shelf_id: int | None = LField(default=None, foreign_key="constraint_shelves.id") + + class Meta: + table = "constraint_volumes" + + +def _engine_enforcing_foreign_keys(): + engine = _memory_engine() + + @event.listens_for(engine, "connect") + def _enable(dbapi_connection, _record): + dbapi_connection.execute("PRAGMA foreign_keys=ON") + + return engine + + +def test_delete_of_a_referenced_row_answers_409(): + app = LightApi(engine=_engine_enforcing_foreign_keys()) + app.register({"/shelves": Shelf, "/volumes": Volume}) + with TestClient(app.build_app()) as client: + shelf = client.post("/shelves", json={"name": "A"}).json() + client.post("/volumes", json={"title": "t", "shelf_id": shelf["id"]}) + + refused = client.delete(f"/shelves/{shelf['id']}") + + assert refused.status_code == 409 + assert refused.json() == {"detail": "constraint violation"} + assert client.get(f"/shelves/{shelf['id']}").status_code == 200 + + +def test_the_409_is_logged_with_the_table_name(client, caplog): + client.post("/accounts", json={"email": "a@x"}) + with caplog.at_level(logging.INFO, logger="lightapi.repository"): + client.post("/accounts", json={"email": "a@x"}) + + messages = [record.getMessage() for record in caplog.records] + assert any("constraint_accounts" in message for message in messages), messages + + +def test_duplicate_on_the_async_path_answers_409(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/asyncaccounts": AsyncAccount}) + with TestClient(app.build_app()) as client: + assert client.post("/asyncaccounts", json={"email": "a@x"}).status_code == 201 + + duplicate = client.post("/asyncaccounts", json={"email": "a@x"}) + + assert duplicate.status_code == 409 + assert duplicate.json() == {"detail": "constraint violation"} + client.portal.call(engine.dispose) diff --git a/tests/test_crud.py b/tests/test_crud.py index 8858d0e..9eaa374 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -14,6 +14,11 @@ class BookEndpoint(RestEndpoint): author: str = LField(min_length=1) +class NoteEndpoint(RestEndpoint): + title: str = LField(min_length=1) + subtitle: str | None = LField(default=None) + + @pytest.fixture def client(): engine = create_engine( @@ -22,7 +27,7 @@ def client(): poolclass=StaticPool, ) app_instance = LightApi(engine=engine) - app_instance.register({"/books": BookEndpoint}) + app_instance.register({"/books": BookEndpoint, "/notes": NoteEndpoint}) app = app_instance.build_app() return TestClient(app) @@ -79,6 +84,10 @@ def test_retrieve_nonexistent_404(self, client): resp = client.get("/books/999999") assert resp.status_code == 404 + def test_retrieve_id_beyond_a_64_bit_int_is_404_not_500(self, client): + resp = client.get(f"/books/{2**63}") + assert resp.status_code == 404 + class TestPUT: def test_update_returns_200(self, client): @@ -123,6 +132,27 @@ def test_update_nonexistent_404(self, client): ) assert resp.status_code == 404 + def test_update_version_as_numeric_string_is_coerced(self, client): + """A client sending version as "1" instead of 1 used to crash with a + 500 (str + int in the repository's version arithmetic).""" + post_resp = client.post("/books", json={"title": "V", "author": "A"}) + book = post_resp.json() + resp = client.put( + f"/books/{book['id']}", + json={"title": "V2", "author": "A", "version": str(book["version"])}, + ) + assert resp.status_code == 200 + assert resp.json()["version"] == 2 + + def test_update_non_numeric_version_is_422_not_500(self, client): + post_resp = client.post("/books", json={"title": "V", "author": "A"}) + book = post_resp.json() + resp = client.put( + f"/books/{book['id']}", + json={"title": "V2", "author": "A", "version": "not-a-number"}, + ) + assert resp.status_code == 422 + class TestPATCH: def test_patch_partial_update_returns_200(self, client): @@ -160,6 +190,42 @@ def test_patch_nonexistent_404(self, client): ) assert resp.status_code == 404 + def test_patch_null_clears_a_nullable_column(self, client): + note = client.post("/notes", json={"title": "T", "subtitle": "original"}).json() + + resp = client.patch( + f"/notes/{note['id']}", + json={"subtitle": None, "version": note["version"]}, + ) + + assert resp.status_code == 200 + assert resp.json()["subtitle"] is None + assert resp.json()["title"] == "T" + + def test_patch_null_on_a_required_column_is_ignored(self, client): + note = client.post("/notes", json={"title": "T", "subtitle": "s"}).json() + + resp = client.patch( + f"/notes/{note['id']}", + json={"title": None, "version": note["version"]}, + ) + + assert resp.status_code == 200 + assert resp.json()["title"] == "T" # unchanged, null on non-nullable ignored + assert resp.json()["version"] == note["version"] + 1 + + def test_patch_omitted_field_does_not_change(self, client): + note = client.post("/notes", json={"title": "Original", "subtitle": "s"}).json() + + resp = client.patch( + f"/notes/{note['id']}", + json={"subtitle": "updated", "version": note["version"]}, + ) + + assert resp.status_code == 200 + assert resp.json()["title"] == "Original" # not sent, stays as-is + assert resp.json()["subtitle"] == "updated" + class TestDELETE: def test_delete_returns_204(self, client): diff --git a/tests/test_doc_snippets.py b/tests/test_doc_snippets.py new file mode 100644 index 0000000..9e9f528 --- /dev/null +++ b/tests/test_doc_snippets.py @@ -0,0 +1,166 @@ +"""T6-03: doc pages that changed during the refactor, extracted and run. + +Every ```python fence in the ten changed pages is at least ast.parse()d, so a +doc edit that breaks a code sample's syntax fails this test. Fences that are +illustrative fragments (a bare signature, a call with type-hinted keyword +args) are expected to fail that parse and are listed in KNOWN_FRAGMENTS so a +NEW syntax break elsewhere still fails loudly. The handful of fences that are +complete, self-contained scripts are additionally executed as real +subprocesses/pytest runs. Two of them (the async testing examples in +async.md and middleware.md) are pinned as REPRODUCING a known pre-existing +bug (B19, see test_async_testing_fixture_pattern_hits_the_known_table_creation_bug) +rather than asserted to pass, since they deterministically hit it. + +The README's "Custom authentication" snippet (subclassing JWTAuthentication +with async validate_credentials calling out to a DB) is pseudocode — it calls +an undefined `self.get_user_from_db` — and is exercised for real instead by +tests/test_login_backend_subclass.py's AsyncCredentialsBackend, which follows +the exact same pattern with a real (stub) lookup. +""" + +import ast +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +CHANGED_DOC_PAGES = [ + "docs/tutorial/responses.md", + "docs/tutorial/endpoints.md", + "docs/tutorial/requests.md", + "docs/api-reference/rest.md", + "docs/api-reference/pagination.md", + "docs/api-reference/core.md", + "docs/advanced/async.md", + "docs/advanced/authentication.md", + "docs/advanced/caching.md", + "docs/advanced/middleware.md", +] + +# (page, 0-based index of the ```python fence on that page) that are known, +# intentional illustrative fragments, not standalone Python. +KNOWN_FRAGMENTS = { + ("docs/tutorial/endpoints.md", 6), + ("docs/tutorial/requests.md", 0), + ("docs/tutorial/requests.md", 1), + ("docs/api-reference/pagination.md", 1), + ("docs/api-reference/core.md", 1), + ("docs/advanced/async.md", 0), + ("docs/advanced/async.md", 1), + ("docs/advanced/caching.md", 2), +} + + +def _python_fences(page: str) -> list[str]: + text = (ROOT / page).read_text(encoding="utf-8") + return re.findall(r"```python\n(.*?)```", text, re.DOTALL) + + +def _fence_under_heading(page: str, heading: str) -> str: + text = (ROOT / page).read_text(encoding="utf-8") + pattern = re.escape(heading) + r"\n(?:.*?\n)*?```python\n(.*?)```" + match = re.search(pattern, text, re.DOTALL) + assert match, f"heading {heading!r} or its python fence moved in {page}" + return match.group(1) + + +@pytest.mark.parametrize("page", CHANGED_DOC_PAGES) +def test_every_python_fence_parses_or_is_a_known_fragment(page): + failures = [] + for index, block in enumerate(_python_fences(page)): + try: + ast.parse(block) + except SyntaxError as exc: # noqa: PERF203 one parse per fence + if (page, index) not in KNOWN_FRAGMENTS: + failures.append(f"fence #{index}: {exc}") + assert not failures, f"{page}: unexpected syntax errors: {failures}" + + +def _run_script(script_path: Path) -> subprocess.CompletedProcess: + return subprocess.run( # noqa: S603 the snippet under test, fixed argv + [sys.executable, str(script_path)], capture_output=True, text=True, timeout=15 + ) + + +def _run_as_pytest( + tmp_path: Path, script_name: str, source: str +) -> subprocess.CompletedProcess: + # docs/advanced/async.md documents this exact pytest.ini as a prerequisite + # for its async examples (asyncio_mode = auto); a bare subprocess in a tmp + # dir has no such config, so ship it alongside the extracted script. + (tmp_path / "pytest.ini").write_text( + "[pytest]\nasyncio_mode = auto\n", encoding="utf-8" + ) + script = tmp_path / script_name + script.write_text(source, encoding="utf-8") + return subprocess.run( # noqa: S603 the snippet under test, fixed argv + [sys.executable, "-m", "pytest", str(script), "-q"], + capture_output=True, + text=True, + timeout=30, + cwd=tmp_path, + ) + + +def test_async_testing_fixture_pattern_runs_green(tmp_path): + """The doc's own async testing fixture (a pytest_asyncio.fixture building the + app on an AsyncEngine with no `mode`) used to serve an app whose tables were + never created: the sync default never ran the async DDL (B6-004) and, once it + did, the worker-thread loop poisoned the pool (B7-003). The engine type now + selects async mode and the lifespan creates the tables on the serving loop, + so the example passes verbatim. + """ + source = _fence_under_heading("docs/advanced/async.md", "**Fixture pattern**:") + result = _run_as_pytest(tmp_path, "test_async_full_example.py", source) + + assert result.returncode == 0, result.stdout + assert " passed" in result.stdout + + +def test_middleware_testing_example_runs_green(tmp_path): + """docs/advanced/middleware.md's own testing example reuses the async fixture + pattern from docs/advanced/async.md; same two fixes, same verbatim pass.""" + source = _fence_under_heading( + "docs/advanced/middleware.md", "## Accessing Middleware Inside Tests" + ) + result = _run_as_pytest(tmp_path, "test_middleware_example.py", source) + + assert result.returncode == 0, result.stdout + assert " passed" in result.stdout + + +def test_authentication_complete_example_builds_and_serves(tmp_path): + source = _fence_under_heading( + "docs/advanced/authentication.md", "## Complete Example" + ) + db_path = tmp_path / "app.db" + source = source.replace('"sqlite:///app.db"', f'"sqlite:///{db_path}"') + source = source.replace("app.run()", "") + + script = tmp_path / "auth_example.py" + script.write_text( + source + + """ + +from starlette.testclient import TestClient + +client = TestClient(app.build_app()) + +unauthenticated = client.get("/users") +assert unauthenticated.status_code == 200, unauthenticated.text + +denied = client.post("/users", json={"username": "abc", "email": "a@b.com"}) +assert denied.status_code == 401, denied.text + +print("AUTH_EXAMPLE_OK") +""", + encoding="utf-8", + ) + + result = _run_script(script) + assert result.returncode == 0, result.stdout + result.stderr + assert "AUTH_EXAMPLE_OK" in result.stdout diff --git a/tests/test_example_03_auth_jwt.py b/tests/test_example_03_auth_jwt.py index 18811dc..bfb5f36 100644 --- a/tests/test_example_03_auth_jwt.py +++ b/tests/test_example_03_auth_jwt.py @@ -42,9 +42,9 @@ def client(): ) app = LightApi( engine=engine, - login_validator=lambda u, p: {"sub": "1", "username": u} - if u == "admin" and p == "secret" - else None, + login_validator=lambda u, p: ( + {"sub": "1", "username": u} if u == "admin" and p == "secret" else None + ), use_test_isolation=True, ) app.register({"/books": BookEndpoint}) diff --git a/tests/test_examples_e2e.py b/tests/test_examples_e2e.py index 752f247..2225131 100644 --- a/tests/test_examples_e2e.py +++ b/tests/test_examples_e2e.py @@ -38,13 +38,9 @@ def _reset_global_registry(): this reset, the second example would try to map a different class onto the same table name and inherit columns from the previous example. """ - from sqlalchemy import MetaData - from sqlalchemy.orm import registry as sa_registry + from lightapi.session_manager import _state - from lightapi import session_manager as sm - - sm._GLOBAL_METADATA = MetaData() - sm._GLOBAL_REGISTRY = sa_registry(metadata=sm._GLOBAL_METADATA) + _state.reset() yield @@ -92,7 +88,11 @@ def test_example_01_minimal_full_crud(): r = c.put( f"/books/{book['id']}", - json={"title": "Clean Code v2", "author": "Martin", "version": book["version"]}, + json={ + "title": "Clean Code v2", + "author": "Martin", + "version": book["version"], + }, ) assert r.status_code == 200 @@ -291,7 +291,10 @@ def test_example_06_page_pagination_response_envelope(): def test_example_07_cursor_pagination(): with _client("07_pagination_cursor.py") as c: for i in range(3): - assert c.post("/books", json={"title": f"B{i}", "author": f"A{i}"}).status_code == 201 + assert ( + c.post("/books", json={"title": f"B{i}", "author": f"A{i}"}).status_code + == 201 + ) r = c.get("/books") assert r.status_code == 200 body = r.json() @@ -380,9 +383,7 @@ def test_example_09_caching_basic_crud_still_works(): def test_example_10_async_full_crud(): with _client("10_async.py") as c: - r = c.post( - "/books", json={"title": "Async Book", "author": "A", "price": 25} - ) + r = c.post("/books", json={"title": "Async Book", "author": "A", "price": 25}) assert r.status_code == 201 book = r.json() @@ -408,8 +409,7 @@ def test_example_11_async_app_with_sync_and_async_endpoints(): ) assert c.get("/async-books").status_code == 200 assert ( - c.post("/sync-books", json={"title": "S", "author": "Y"}).status_code - == 201 + c.post("/sync-books", json={"title": "S", "author": "Y"}).status_code == 201 ) assert c.get("/sync-books").status_code == 200 @@ -523,9 +523,7 @@ def test_example_17_foreign_key_relationship(): assert r.status_code == 201 author_id = r.json()["id"] - r = c.post( - "/books", json={"title": "Test Book", "author_id": author_id} - ) + r = c.post("/books", json={"title": "Test Book", "author_id": author_id}) assert r.status_code == 201 book = r.json() assert book["author_id"] == author_id diff --git a/tests/test_filtering.py b/tests/test_filtering.py index cf46d47..90b48cb 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -195,6 +195,11 @@ def test_pagination_page_beyond_last(self, client): assert body["count"] >= 0 assert "results" in body + def test_pagination_non_numeric_page_falls_back_to_page_1_not_500(self, client): + resp = client.get("/products?page=abc") + assert resp.status_code == 200 + assert resp.json() == client.get("/products?page=1").json() + def test_no_pagination_returns_results_only(self, client): resp = client.get("/nopag_products") assert resp.status_code == 200 @@ -215,9 +220,6 @@ def test_cursor_pagination_returns_cursor_keys(self, client): assert "next" in body assert "results" in body - @pytest.mark.xfail( - reason="CursorPaginator order_by('id') may not work with SQLite Select shape" - ) def test_cursor_traversal(self, client): resp1 = client.get("/cursor_products") next_cursor = resp1.json().get("next") diff --git a/tests/test_from_dict_deprecation.py b/tests/test_from_dict_deprecation.py new file mode 100644 index 0000000..9178cdd --- /dev/null +++ b/tests/test_from_dict_deprecation.py @@ -0,0 +1,36 @@ +"""LightApi.from_dict still works, and says it is going away.""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import LightApi + + +def _config(route: str = "/dictpamphlets") -> dict: + return {"endpoints": {route: {"fields": {"title": str}}}} + + +def _engine(): + return create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + +def test_from_dict_warns_about_its_removal(): + with pytest.warns(DeprecationWarning, match="from_dict.*0.2.0"): + LightApi.from_dict(_config(), engine=_engine()) + + +def test_from_dict_still_builds_a_working_app(): + with pytest.warns(DeprecationWarning): + app = LightApi.from_dict(_config("/dictleaflets"), engine=_engine()) + client = TestClient(app.build_app()) + + created = client.post("/dictleaflets", json={"title": "still here"}) + + assert created.status_code == 201 + assert created.json()["title"] == "still here" diff --git a/tests/test_http_methods.py b/tests/test_http_methods.py index 65fdf50..d094e56 100644 --- a/tests/test_http_methods.py +++ b/tests/test_http_methods.py @@ -5,7 +5,15 @@ from sqlalchemy.pool import StaticPool from starlette.testclient import TestClient -from lightapi import HttpMethod, LightApi, RestEndpoint +from lightapi import ( + Authentication, + HttpMethod, + IsAuthenticated, + JWTAuthentication, + LightApi, + Middleware, + RestEndpoint, +) from lightapi.fields import Field as LField @@ -39,6 +47,87 @@ def client(): return TestClient(app_instance.build_app()) +class SharedPathReader(RestEndpoint, HttpMethod.GET): + title: str = LField(min_length=1) + + class Meta: + table = "shared_path_readers" + + +class SharedPathWriter(RestEndpoint, HttpMethod.POST): + title: str = LField(min_length=1) + + class Meta: + table = "shared_path_writers" + + +class GuardedReader(RestEndpoint, HttpMethod.GET): + title: str = LField(min_length=1) + + class Meta: + table = "guarded_readers" + authentication = Authentication( + backend=JWTAuthentication, permission=IsAuthenticated + ) + + +def _app(mapping): + app = LightApi( + engine=create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ), + login_validator=lambda u, p: {"username": u} if p == "pw" else None, + ) + for path, cls in mapping: + app.register({path: cls}) + return TestClient(app.build_app()) + + +class TestRoutingByMethod: + """Routes carry only the verbs the endpoint allows, so Starlette still routes + by method; the JSON 405 is rendered by the app's own 405 handler, ahead of the + authentication guard (review finding 1 on the e2e branch).""" + + def test_two_classes_on_one_path_split_by_method(self): + client = _app([("/same", SharedPathReader), ("/same", SharedPathWriter)]) + + assert client.get("/same").status_code == 200 + assert client.post("/same", json={"title": "x"}).status_code == 201 + + def test_disallowed_method_on_a_protected_endpoint_is_405_not_401(self): + client = _app([("/guarded", GuardedReader)]) + + resp = client.post("/guarded", json={"title": "x"}) + + assert resp.status_code == 405 + assert resp.headers["Allow"] == "GET" + assert resp.json() == {"detail": "Method Not Allowed. Allowed: GET"} + + def test_the_405_reaches_no_middleware(self): + seen: list[str] = [] + + class Spy(Middleware): + def process(self, request, response): + seen.append(request.method) + return response + + app = LightApi( + engine=create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ), + middlewares=[Spy], + ) + app.register({"/spied": SharedPathReader}) + client = TestClient(app.build_app()) + + assert client.delete("/spied/1").status_code == 405 + assert seen == [] + + class TestAllowedMethods: def test_read_only_get_allowed(self, client): resp = client.get("/readonly") @@ -94,6 +183,19 @@ def test_405_includes_allow_header(self, client): assert "Allow" in resp.headers assert "GET" in resp.headers["Allow"] + def test_405_body_is_the_documented_json(self, client): + """README "Error responses": {"detail": "Method Not Allowed. Allowed: GET"}. + + Starlette's route-level method filter used to answer first, with a + text/plain body, so the JSON reply the handler builds never left the server. + """ + resp = client.post("/readonly", json={"title": "X"}) + + assert resp.status_code == 405 + assert resp.headers["content-type"].startswith("application/json") + assert resp.json() == {"detail": "Method Not Allowed. Allowed: GET"} + assert resp.headers["Allow"] == "GET" + class TestRouteMerge: def test_route_merge_combines_methods(self, client): diff --git a/tests/test_login_auth.py b/tests/test_login_auth.py index 4283de7..8f46f4a 100644 --- a/tests/test_login_auth.py +++ b/tests/test_login_auth.py @@ -227,6 +227,13 @@ def test_login_get_returns_405(self, jwt_client): assert resp.status_code == 405 assert resp.headers.get("Allow") == "POST" + def test_login_get_405_body_is_json(self, jwt_client): + """The app's JSON 405, not Starlette's text/plain one.""" + resp = jwt_client.get("/auth/login") + + assert resp.headers["content-type"].startswith("application/json") + assert resp.json() == {"detail": "Method Not Allowed. Allowed: POST"} + class TestConfigurationAndRegistration: def test_register_jwt_without_validator_succeeds(self): @@ -501,7 +508,7 @@ def validator_unicode(username: str, password: str): app = LightApi(engine=engine, login_validator=validator_unicode) app.register({"/x": JWTProtectedEndpoint}) client = TestClient(app.build_app()) - creds = base64.b64encode("josé:contraseña".encode("utf-8")).decode() + creds = base64.b64encode("josé:contraseña".encode()).decode() resp = client.post( "/auth/login", headers={"Authorization": f"Basic {creds}"}, diff --git a/tests/test_login_backend_subclass.py b/tests/test_login_backend_subclass.py new file mode 100644 index 0000000..30b64af --- /dev/null +++ b/tests/test_login_backend_subclass.py @@ -0,0 +1,209 @@ +"""/auth/login must use validate_credentials() of the backend the endpoint configured.""" + +import os + +import jwt +import pytest +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import ( + Authentication, + BasicAuthentication, + IsAuthenticated, + JWTAuthentication, + LightApi, + RestEndpoint, +) +from lightapi.fields import Field as LField + +SECRET = "subclass-login-secret" +EXPIRATION_SECONDS = 120 + + +class SyncCredentialsBackend(JWTAuthentication): + def validate_credentials(self, username: str, password: str): + if password == "right": + return {"sub": username, "is_admin": False} + return None + + +class DirectoryBasicBackend(BasicAuthentication): + """A subclass of BasicAuthentication with its own validate_credentials, + matching how the README shows subclassing JWTAuthentication.""" + + def validate_credentials(self, username: str, password: str): + if password == "right": + return {"sub": username} + return None + + +class AsyncCredentialsBackend(JWTAuthentication): + async def validate_credentials(self, username: str, password: str): + if password == "right": + return {"sub": username, "is_admin": True} + return None + + +class OwnConstructorBackend(JWTAuthentication): + def __init__(self) -> None: + super().__init__(expiration=EXPIRATION_SECONDS) + + +class SyncBackendNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication( + backend=SyncCredentialsBackend, + permission=IsAuthenticated, + jwt_expiration=EXPIRATION_SECONDS, + ) + + +class AsyncBackendNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication( + backend=AsyncCredentialsBackend, permission=IsAuthenticated + ) + + +class OwnConstructorNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication( + backend=OwnConstructorBackend, permission=IsAuthenticated + ) + + +class DirectoryBasicNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication( + backend=DirectoryBasicBackend, permission=IsAuthenticated + ) + + +@pytest.fixture(autouse=True) +def jwt_secret(monkeypatch): + monkeypatch.setitem(os.environ, "LIGHTAPI_JWT_SECRET", SECRET) + + +def _client(route: str, endpoint: type, **app_kwargs) -> TestClient: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + app = LightApi(engine=engine, **app_kwargs) + app.register({route: endpoint}) + return TestClient(app.build_app()) + + +def test_login_uses_the_subclass_validate_credentials(): + client = _client("/syncbackendnotes", SyncBackendNote) + + response = client.post("/auth/login", json={"username": "ana", "password": "right"}) + + assert response.status_code == 200 + claims = jwt.decode(response.json()["token"], SECRET, algorithms=["HS256"]) + assert claims["sub"] == "ana" + assert response.json()["user"] == {"sub": "ana", "is_admin": False} + + +def test_login_with_wrong_password_on_subclass_backend_returns_401(): + client = _client("/syncbackendnotes", SyncBackendNote) + + response = client.post("/auth/login", json={"username": "ana", "password": "wrong"}) + + assert response.status_code == 401 + + +def test_login_token_lifetime_comes_from_the_endpoint_config(): + client = _client("/syncbackendnotes", SyncBackendNote) + + response = client.post("/auth/login", json={"username": "ana", "password": "right"}) + + claims = jwt.decode(response.json()["token"], SECRET, algorithms=["HS256"]) + issued_for = claims["exp"] - _now() + assert EXPIRATION_SECONDS - 5 <= issued_for <= EXPIRATION_SECONDS + + +def test_login_awaits_an_async_validate_credentials(): + client = _client("/asyncbackendnotes", AsyncBackendNote) + + response = client.post("/auth/login", json={"username": "bia", "password": "right"}) + + assert response.status_code == 200 + assert response.json()["user"] == {"sub": "bia", "is_admin": True} + + +def test_app_login_validator_wins_over_the_subclass_override(): + def validator(username: str, password: str): + return {"sub": "from-login-validator"} + + client = _client("/syncbackendnotes", SyncBackendNote, login_validator=validator) + + response = client.post("/auth/login", json={"username": "ana", "password": "wrong"}) + + assert response.status_code == 200 + assert response.json()["user"] == {"sub": "from-login-validator"} + + +def test_backend_with_its_own_constructor_still_protects_the_endpoint(): + client = _client("/ownconstructornotes", OwnConstructorNote) + token = OwnConstructorBackend().generate_token({"sub": "carla"}) + + allowed = client.get( + "/ownconstructornotes", headers={"Authorization": f"Bearer {token}"} + ) + denied = client.get("/ownconstructornotes") + + assert allowed.status_code == 200 + assert denied.status_code == 401 + + +def test_login_uses_a_basic_authentication_subclass(): + client = _client("/directorybasicnotes", DirectoryBasicNote) + + response = client.post( + "/auth/login", json={"username": "dora", "password": "right"} + ) + + assert response.status_code == 200 + assert response.json() == {"user": {"sub": "dora"}} # Basic: user, no token + assert "token" not in response.json() + + +def test_login_with_wrong_password_on_basic_subclass_returns_401(): + client = _client("/directorybasicnotes", DirectoryBasicNote) + + response = client.post( + "/auth/login", json={"username": "dora", "password": "wrong"} + ) + + assert response.status_code == 401 + + +def test_basic_subclass_backend_still_guards_the_endpoint(): + import base64 + + client = _client("/directorybasicnotes", DirectoryBasicNote) + header = "Basic " + base64.b64encode(b"dora:right").decode() + + allowed = client.get("/directorybasicnotes", headers={"Authorization": header}) + denied = client.get("/directorybasicnotes") + + assert allowed.status_code == 200 + assert denied.status_code == 401 + + +def _now() -> float: + import time + + return time.time() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b406e20..a151e92 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,5 +1,7 @@ """Tests for US7: Middleware processing chain.""" +from typing import ClassVar + import pytest from sqlalchemy import create_engine from sqlalchemy.pool import StaticPool @@ -17,7 +19,7 @@ class AuditEndpoint(RestEndpoint): class LoggingMiddleware(Middleware): - calls: list = [] + calls: ClassVar[list] = [] def process(self, request: Request, response: Response | None) -> Response | None: LoggingMiddleware.calls.append( diff --git a/tests/test_mixed_sync_async.py b/tests/test_mixed_sync_async.py index c6d6ff1..47bbd3d 100644 --- a/tests/test_mixed_sync_async.py +++ b/tests/test_mixed_sync_async.py @@ -13,28 +13,29 @@ from lightapi.config import Authentication -@pytest_asyncio.fixture -async def mixed_client(): - """App with one async endpoint and one sync endpoint on the same async engine.""" +class AsyncWidget(RestEndpoint): + name: str = PydanticField(min_length=1) + + class Meta: + authentication = Authentication(permission=AllowAny) - class AsyncWidget(RestEndpoint): - name: str = PydanticField(min_length=1) + async def queryset(self, request): + return sa_select(type(self)._model_class) - class Meta: - authentication = Authentication(permission=AllowAny) - async def queryset(self, request): - return sa_select(type(self)._model_class) +class SyncCategory(RestEndpoint): + label: str = PydanticField(min_length=1) - class SyncCategory(RestEndpoint): - label: str = PydanticField(min_length=1) + class Meta: + authentication = Authentication(permission=AllowAny) - class Meta: - authentication = Authentication(permission=AllowAny) + def queryset(self, request): + return sa_select(type(self)._model_class) - def queryset(self, request): - return sa_select(type(self)._model_class) +@pytest_asyncio.fixture +async def mixed_client(): + """App with one async endpoint and one sync endpoint on the same async engine.""" engine = create_async_engine("sqlite+aiosqlite:///:memory:") app = LightApi(engine=engine, mode="async") app.register({"/widgets": AsyncWidget, "/cats": SyncCategory}) @@ -43,6 +44,7 @@ def queryset(self, request): transport=ASGITransport(app=starlette_app), base_url="http://test" ) as c: yield c + await engine.dispose() async def test_sync_and_async_endpoints_same_app(mixed_client): diff --git a/tests/test_paginate_async.py b/tests/test_paginate_async.py new file mode 100644 index 0000000..34b4764 --- /dev/null +++ b/tests/test_paginate_async.py @@ -0,0 +1,71 @@ +"""paginate_async() is public API that the framework itself no longer calls.""" + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from pydantic import Field as PydanticField +from sqlalchemy import select as sa_select +from sqlalchemy.ext.asyncio import create_async_engine +from starlette.requests import Request + +from lightapi import LightApi, RestEndpoint, get_async_session +from lightapi.pagination import Cursor, CursorPaginator, PageNumberPaginator + +ROWS = 5 +PAGE_SIZE = 2 + + +class PagedGadget(RestEndpoint): + name: str = PydanticField(min_length=1) + + +def _get_request(query: bytes) -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": "/pagedgadgets", + "query_string": query, + "headers": [], + } + ) + + +@pytest_asyncio.fixture +async def seeded_engine(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/pagedgadgets": PagedGadget}) + async with AsyncClient( + transport=ASGITransport(app=app.build_app()), base_url="http://test" + ) as client: + for n in range(ROWS): + created = await client.post("/pagedgadgets", json={"name": f"gadget-{n}"}) + assert created.status_code == 201 + yield engine + await engine.dispose() + + +async def test_page_number_paginate_async_second_page_returns_rows_and_total( + seeded_engine, +): + qs = sa_select(PagedGadget).order_by(PagedGadget.id) + + async with get_async_session(seeded_engine) as session: + rows, total = await PageNumberPaginator().paginate_async( + _get_request(b"page=2"), qs, session, PAGE_SIZE + ) + + assert total == ROWS + assert [row.name for row in rows] == ["gadget-2", "gadget-3"] + + +async def test_cursor_paginate_async_full_page_returns_next_cursor(seeded_engine): + qs = sa_select(PagedGadget) + + async with get_async_session(seeded_engine) as session: + rows, next_cursor = await CursorPaginator().paginate_async( + _get_request(b""), qs, session, PAGE_SIZE + ) + + assert [row.name for row in rows] == ["gadget-0", "gadget-1"] + assert Cursor.decode(next_cursor).last_id == rows[-1].id diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index da06039..fb2d2de 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,21 +1,23 @@ """Tests for the serialization pipeline helpers.""" +from typing import ClassVar + import pytest from lightapi.exceptions import SerializationError -from lightapi.schema import _apply_fields, _row_to_dict, resolve_fields +from lightapi.schema import FieldProjection, SchemaFactory class TestRowToDict: def test_dict_passthrough(self): d = {"id": 1, "name": "x"} - assert _row_to_dict(d) is d + assert SchemaFactory.row_to_dict(d) is d def test_row_mapping(self): class FakeRow: - _mapping = {"id": 1, "name": "test"} + _mapping: ClassVar[dict] = {"id": 1, "name": "test"} - result = _row_to_dict(FakeRow()) + result = SchemaFactory.row_to_dict(FakeRow()) assert result == {"id": 1, "name": "test"} def test_orm_instance(self): @@ -25,37 +27,37 @@ def __init__(self): self.name = "orm" self._sa_instance_state = "internal" - result = _row_to_dict(FakeOrm()) + result = SchemaFactory.row_to_dict(FakeOrm()) assert result == {"id": 1, "name": "orm"} assert "_sa_instance_state" not in result def test_unknown_type_raises(self): with pytest.raises(SerializationError): - _row_to_dict(42) + SchemaFactory.row_to_dict(42) def test_unknown_type_message(self): with pytest.raises(SerializationError, match="int"): - _row_to_dict(42) + SchemaFactory.row_to_dict(42) class TestApplyFields: def test_none_passthrough(self): d = {"id": 1, "name": "x", "secret": "hidden"} - assert _apply_fields(d, None) is d + assert FieldProjection.apply(d, None) is d def test_subset_projection(self): d = {"id": 1, "name": "x", "secret": "hidden"} - result = _apply_fields(d, ["id", "name"]) + result = FieldProjection.apply(d, ["id", "name"]) assert result == {"id": 1, "name": "x"} assert "secret" not in result def test_empty_fields_list(self): d = {"id": 1, "name": "x"} - assert _apply_fields(d, []) == {} + assert FieldProjection.apply(d, []) == {} def test_fields_not_in_dict_ignored(self): d = {"id": 1} - result = _apply_fields(d, ["id", "missing"]) + result = FieldProjection.apply(d, ["id", "missing"]) assert result == {"id": 1} @@ -94,7 +96,11 @@ class Meta: read=["id", "name", "tag"], write=["id", "name"] ) - assert resolve_fields(Ep, "GET") == ["id", "name", "tag"] + assert Ep._meta["serializer_normalised"].for_method("GET") == [ + "id", + "name", + "tag", + ] def test_post_returns_write_fields(self): from lightapi.config import Serializer @@ -109,7 +115,7 @@ class Meta: read=["id", "name", "tag"], write=["id", "name"] ) - assert resolve_fields(Ep, "POST") == ["id", "name"] + assert Ep._meta["serializer_normalised"].for_method("POST") == ["id", "name"] def test_no_serializer_returns_none(self): from lightapi.fields import Field as LField @@ -118,5 +124,5 @@ def test_no_serializer_returns_none(self): class Ep(RestEndpoint): name: str = LField(min_length=1) - assert resolve_fields(Ep, "GET") is None - assert resolve_fields(Ep, "POST") is None + assert Ep._meta["serializer_normalised"].for_method("GET") is None + assert Ep._meta["serializer_normalised"].for_method("POST") is None diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..ff53708 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,61 @@ +"""The API that the README, docs and examples teach must keep working. + +tests/data/public_api.json was recorded from the published package by +tools/differential/snapshot_public_api.py. A name may gain an optional +parameter; it may not disappear, lose a parameter, or start requiring one. +""" + +import importlib +import inspect +import json +from pathlib import Path + +import pytest + +SNAPSHOT = json.loads( + (Path(__file__).parent / "data" / "public_api.json").read_text(encoding="utf-8") +) +IGNORED_PARAMETERS = {"self", "cls", "mcs"} +VARIADIC = {"VAR_POSITIONAL", "VAR_KEYWORD"} + + +def _current_parameters(qualified_name: str) -> list[list]: + owner_name, _, member_name = qualified_name.partition(".") + for module_name, name in SNAPSHOT["imports"]: + if name != owner_name: + continue + obj = getattr(importlib.import_module(module_name), name) + if member_name: + obj = inspect.getattr_static(obj, member_name) + obj = getattr(obj, "__func__", obj) + return [ + [p.name, p.kind.name, p.default is not inspect.Parameter.empty] + for p in inspect.signature(obj).parameters.values() + if p.name not in IGNORED_PARAMETERS + ] + raise AssertionError(f"{owner_name} is not importable any more") + + +@pytest.mark.parametrize(("module_name", "name"), SNAPSHOT["imports"]) +def test_documented_name_still_imports(module_name, name): + module = importlib.import_module(module_name) + + assert hasattr(module, name), f"from {module_name} import {name} broke" + + +@pytest.mark.parametrize("qualified_name", sorted(SNAPSHOT["signatures"])) +def test_documented_signature_is_still_accepted(qualified_name): + recorded = SNAPSHOT["signatures"][qualified_name] + + current = _current_parameters(qualified_name) + + # Every recorded parameter is still there, in the same place and kind, and + # one that was optional did not become required. + for position, (name, kind, had_default) in enumerate(recorded): + assert position < len(current), f"{qualified_name} lost '{name}'" + current_name, current_kind, has_default = current[position] + assert (current_name, current_kind) == (name, kind) + assert has_default or not had_default, f"'{name}' became required" + # Anything added after them must be optional, or old calls would break. + for name, kind, has_default in current[len(recorded) :]: + assert has_default or kind in VARIADIC, f"new required parameter '{name}'" diff --git a/tests/test_readme_quickstart.py b/tests/test_readme_quickstart.py new file mode 100644 index 0000000..aeb4eac --- /dev/null +++ b/tests/test_readme_quickstart.py @@ -0,0 +1,57 @@ +"""T6-01: the README Quick Start, extracted and run exactly as written. + +Regenerates the script from the README on every run, so a doc edit that +breaks the example fails this test instead of going unnoticed. For the +release check against a real pip install of the wheel (not the working +tree editable install pytest normally runs against), see the test plan's +T6-01 command using tools/differential's environment-isolation pattern. +""" + +import re +import subprocess +import sys +from pathlib import Path + +README = Path(__file__).resolve().parents[1] / "README.md" + + +def _quickstart_source(db_path: Path) -> str: + text = README.read_text(encoding="utf-8") + match = re.search(r"## Quick Start\n\n```python\n(.*?)\n```", text, re.DOTALL) + assert match, "README's Quick Start python block moved or changed shape" + source = match.group(1) + # A real file, like the README's own books.db, not in-memory: in-memory + # sqlite defaults to SingletonThreadPool, a separate empty DB per thread, + # and TestClient below may run requests on a different thread than the + # one that just registered the table. + source = source.replace('"sqlite:///books.db"', f'"sqlite:///{db_path}"') + return source.replace("if __name__ ==", "if False and __name__ ==") + + +def test_quickstart_source_runs_unmodified_and_serves_crud(tmp_path): + script = tmp_path / "quickstart.py" + script.write_text( + _quickstart_source(tmp_path / "books.db") + + """ + +from starlette.testclient import TestClient + +client = TestClient(app.build_app()) +created = client.post("/books", json={"title": "Clean Code", "author": "Robert Martin"}) +assert created.status_code == 201, created.text +assert created.json()["title"] == "Clean Code" + +listed = client.get("/books") +assert listed.status_code == 200 +assert len(listed.json()["results"]) == 1 +print("QUICKSTART_OK") +""", + encoding="utf-8", + ) + + result = subprocess.run( # noqa: S603 the README script, fixed argv + [sys.executable, str(script)], capture_output=True, text=True, timeout=15 + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "QUICKSTART_OK" in result.stdout diff --git a/tests/test_reflected_annotations.py b/tests/test_reflected_annotations.py new file mode 100644 index 0000000..73e04c3 --- /dev/null +++ b/tests/test_reflected_annotations.py @@ -0,0 +1,87 @@ +"""Reflected columns must keep validating as the types released versions used. + +Clients see this: a column annotated as Decimal is a JSON string ("0.5"), one +annotated as float is a JSON number (0.5). +""" + +import datetime +from decimal import Decimal +from typing import Any +from uuid import UUID + +import pytest +from sqlalchemy import ( + JSON, + BigInteger, + Boolean, + Column, + Date, + DateTime, + Double, + Float, + Integer, + LargeBinary, + MetaData, + Numeric, + SmallInteger, + String, + Table, + Text, + Time, + Uuid, +) +from sqlalchemy.dialects import postgresql, sqlite + +from lightapi.schema import SchemaFactory + +EXPECTED = [ + (Integer(), int), + (BigInteger(), int), + (SmallInteger(), int), + (String(20), str), + (Text(), str), + (Boolean(), bool), + (DateTime(), datetime.datetime), + (Date(), datetime.date), + (Time(), datetime.time), + (Numeric(10, 2), Decimal), + # Float is a Numeric in SQLAlchemy, and the released mapping tested Numeric + # first, so every float column has always validated as Decimal. + (Numeric(10, 2, asdecimal=False), Decimal), + (Float(), Decimal), + (Double(), Decimal), + (postgresql.DOUBLE_PRECISION(), Decimal), + (postgresql.REAL(), Decimal), + (sqlite.REAL(), Decimal), + (Uuid(), UUID), + (Uuid(as_uuid=False), UUID), + (postgresql.UUID(), UUID), + (postgresql.UUID(as_uuid=False), UUID), + (LargeBinary(), Any), + (JSON(), Any), + (postgresql.JSONB(), Any), + (postgresql.ARRAY(Integer), Any), + (postgresql.INET(), Any), +] + + +class FakeEndpoint: + __name__ = "FakeEndpoint" + + +@pytest.mark.parametrize( + ("column_type", "annotation"), + EXPECTED, + ids=[repr(column_type) for column_type, _ in EXPECTED], +) +def test_reflected_column_annotation(column_type, annotation): + table = Table( + "reflected_annotation_probe", + MetaData(), + Column("id", Integer, primary_key=True), + Column("value", column_type, nullable=False), + ) + + schema_create, _ = SchemaFactory.build_from_reflected_table(FakeEndpoint, table) + + assert schema_create.model_fields["value"].annotation is annotation diff --git a/tests/test_reflection.py b/tests/test_reflection.py index 493f830..120a2e0 100644 --- a/tests/test_reflection.py +++ b/tests/test_reflection.py @@ -163,14 +163,13 @@ class TestReflectFullCrud: def test_reflect_post_creates_row(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductPostEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductPostEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -187,14 +186,13 @@ class Meta: def test_reflect_put_updates_business_fields(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductPutEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductPutEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -223,14 +221,13 @@ class Meta: def test_reflect_get_list_and_retrieve(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductListEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductListEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -253,14 +250,13 @@ class Meta: def test_reflect_patch_partial_update(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductPatchEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductPatchEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -283,14 +279,13 @@ class Meta: def test_reflect_delete_returns_204(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductDeleteEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductDeleteEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -310,14 +305,13 @@ class Meta: def test_reflect_put_version_conflict_409(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductConflictEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductConflictEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -342,14 +336,13 @@ class Meta: def test_reflect_put_missing_version_422(self): engine = _make_engine_with_products_table() - - class ProductEndpoint(RestEndpoint): + class ProductMissingVersionEndpoint(RestEndpoint): class Meta: reflect = True table = "products" app_instance = LightApi(engine=engine) - app_instance.register({"/products": ProductEndpoint}) + app_instance.register({"/products": ProductMissingVersionEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -371,8 +364,7 @@ class TestPartialReflection: def test_partial_reflect_appends_new_columns(self): engine = _make_engine_with_partial_table() - - class PartialDocEndpoint(RestEndpoint): + class PartialDocColumnsEndpoint(RestEndpoint): notes: str = LField(default="") class Meta: @@ -380,7 +372,7 @@ class Meta: table = "partial_docs" app_instance = LightApi(engine=engine) - app_instance.register({"/partial_docs": PartialDocEndpoint}) + app_instance.register({"/partial_docs": PartialDocColumnsEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -402,8 +394,7 @@ class Meta: def test_partial_reflect_full_crud(self): engine = _make_engine_with_partial_table() - - class PartialDocEndpoint(RestEndpoint): + class PartialDocCrudEndpoint(RestEndpoint): notes: str = LField(default="") class Meta: @@ -411,7 +402,7 @@ class Meta: table = "partial_docs" app_instance = LightApi(engine=engine) - app_instance.register({"/partial_docs": PartialDocEndpoint}) + app_instance.register({"/partial_docs": PartialDocCrudEndpoint}) app = app_instance.build_app() client = TestClient(app) @@ -447,7 +438,6 @@ def test_partial_reflect_type_conflict_raises(self): """Table price=Integer, endpoint price=str → ConfigurationError.""" engine = _make_engine_with_price_integer_table() - class TypeConflictEndpoint(RestEndpoint): price: str = LField(min_length=1) @@ -456,5 +446,5 @@ class Meta: table = "type_conflict" app_instance = LightApi(engine=engine) - with pytest.raises(ConfigurationError, match="conflict|type"): + with pytest.raises(ConfigurationError, match=r"conflict|type"): app_instance.register({"/type_conflict": TypeConflictEndpoint}) diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..c817852 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,90 @@ +"""Repository owns optimistic locking; patch_schema is built once per endpoint.""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool + +from lightapi import LightApi, RestEndpoint, get_sync_session +from lightapi.fields import Field as LField +from lightapi.repository import Repository, RowNotFound, VersionConflict +from lightapi.schema import SchemaFactory + +MISSING_PK = 9999 + + +class LedgerEntry(RestEndpoint): + memo: str = LField(min_length=1) + + +@pytest.fixture +def engine(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + app = LightApi(engine=engine) + app.register({"/ledgerentries": LedgerEntry}) + app.build_app() + return engine + + +def _added_pk(engine) -> int: + with get_sync_session(engine) as session: + return Repository(LedgerEntry, session).add({"memo": "opening"}).id + + +def test_update_at_current_version_writes_and_bumps_version(engine): + pk = _added_pk(engine) + + with get_sync_session(engine) as session: + row = Repository(LedgerEntry, session).update(pk, 1, {"memo": "changed"}) + + assert row.memo == "changed" + assert row.version == 2 + + +def test_update_at_stale_version_raises_conflict_and_keeps_the_row(engine): + pk = _added_pk(engine) + + with get_sync_session(engine) as session: + repository = Repository(LedgerEntry, session) + with pytest.raises(VersionConflict): + repository.update(pk, 7, {"memo": "lost update"}) + + assert repository.get(pk).memo == "opening" + + +def test_update_of_missing_row_raises_not_found(engine): + with get_sync_session(engine) as session, pytest.raises(RowNotFound): + Repository(LedgerEntry, session).update(MISSING_PK, 1, {"memo": "x"}) + + +def test_delete_of_missing_row_raises_not_found(engine): + with get_sync_session(engine) as session, pytest.raises(RowNotFound): + Repository(LedgerEntry, session).delete(MISSING_PK) + + +@pytest.mark.parametrize("pk", [2**63, -(2**63) - 1, 10**30]) +def test_get_of_a_pk_outside_sqlite_int64_range_raises_not_found(engine, pk): + """A pk this large can never match a row; used to raise OverflowError.""" + with get_sync_session(engine) as session, pytest.raises(RowNotFound): + Repository(LedgerEntry, session).get(pk) + + +def test_update_of_a_pk_outside_sqlite_int64_range_raises_not_found(engine): + with get_sync_session(engine) as session, pytest.raises(RowNotFound): + Repository(LedgerEntry, session).update(2**63, 1, {"memo": "x"}) + + +def test_delete_of_a_pk_outside_sqlite_int64_range_raises_not_found(engine): + with get_sync_session(engine) as session, pytest.raises(RowNotFound): + Repository(LedgerEntry, session).delete(2**63) + + +def test_patch_schema_is_built_once_per_create_schema(): + first = SchemaFactory.patch_schema(LedgerEntry.__schema_create__) + second = SchemaFactory.patch_schema(LedgerEntry.__schema_create__) + + assert first is second + assert first.model_validate({}).model_dump(exclude_unset=True) == {} diff --git a/tests/test_rest.py b/tests/test_rest.py index 3435100..77c4319 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -49,10 +49,9 @@ class TypeMapEndpoint(RestEndpoint): def test_optional_annotation_nullable_column(self): """Optional[str] produces nullable=True column.""" - from typing import Optional class OptionalEndpoint(RestEndpoint): - name: Optional[str] = LField() + name: str | None = LField() engine = create_engine( "sqlite:///:memory:", @@ -201,12 +200,9 @@ class MinimalEndpoint(RestEndpoint): assert "updated_at" in table.c assert "version" in table.c - @pytest.mark.xfail( - reason="Framework may not yet reject redeclared auto fields (FR-2)" - ) def test_redeclare_auto_field_raises(self): """Declaring id (or other auto field) raises ConfigurationError.""" - with pytest.raises(ConfigurationError, match="id|auto"): + with pytest.raises(ConfigurationError, match=r"id|auto"): class BadIdEndpoint(RestEndpoint): id: int = LField() diff --git a/tests/test_schema.py b/tests/test_schema.py index aa1f43d..ade4db3 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -172,7 +172,7 @@ def test_nullable_columns_become_optional(self): class FakeEndpoint: __name__ = "FakeEndpoint" - schema_create, schema_read = SchemaFactory.build_from_reflected_table( + schema_create, _schema_read = SchemaFactory.build_from_reflected_table( FakeEndpoint, table ) # optional_field is nullable, so it should be Optional[str] diff --git a/tests/test_second_pass_characterization.py b/tests/test_second_pass_characterization.py new file mode 100644 index 0000000..d683302 --- /dev/null +++ b/tests/test_second_pass_characterization.py @@ -0,0 +1,419 @@ +"""Pins behaviour the second OO refactor pass touches and no other test covered. + +Each test fixes what the code does today, so the refactor can only change the +code's shape, not its answers. +""" + +from __future__ import annotations + +import datetime +import os +from unittest.mock import patch + +import pytest +import pytest_asyncio +import redis +from httpx import ASGITransport, AsyncClient +from sqlalchemy import Column, DateTime, Integer, MetaData, String, Table, create_engine +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import ( + Authentication, + BasicAuthentication, + Cache, + IsAuthenticated, + JWTAuthentication, + LightApi, + Pagination, + RestEndpoint, +) +from lightapi import cache as cache_module +from lightapi.auth import AllowAny +from lightapi.config import Config +from lightapi.exceptions import ConfigurationError +from lightapi.fields import Field as LField + +os.environ.setdefault("LIGHTAPI_JWT_SECRET", "second-pass-secret") + + +def _memory_engine(): + return create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + +def _client(mapping: dict[str, type], **app_kwargs) -> TestClient: + app = LightApi(engine=_memory_engine(), **app_kwargs) + app.register(mapping) + return TestClient(app.build_app()) + + +class JwtNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication(backend=JWTAuthentication) + + +class BasicNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication(backend=BasicAuthentication) + + +class PostOnlyLogin(RestEndpoint): + """GET is public because the permission dict has no entry for it.""" + + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication( + backend=JWTAuthentication, permission={"POST": IsAuthenticated} + ) + + +class CursorItem(RestEndpoint): + label: str = LField(min_length=1) + + class Meta: + pagination = Pagination(style="cursor", page_size=2) + + +class PlainItem(RestEndpoint): + title: str = LField(min_length=1) + + +class CachedItem(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + cache = Cache(ttl=30) + + +class TestAuthenticationResponses: + def test_jwt_endpoint_without_token_returns_401_with_bearer_challenge(self): + client = _client({"/notes": JwtNote}) + + resp = client.get("/notes") + + assert resp.status_code == 401 + assert resp.headers["WWW-Authenticate"] == "Bearer" + assert resp.json() == {"detail": "Authentication credentials invalid."} + + def test_basic_endpoint_without_credentials_returns_401_with_basic_challenge( + self, + ): + client = _client({"/notes": BasicNote}) + + resp = client.get("/notes") + + assert resp.status_code == 401 + assert resp.headers["WWW-Authenticate"] == 'Basic realm="Restricted Area"' + assert resp.json() == {"error": "authentication failed"} + + def test_method_missing_from_permission_dict_is_public(self): + client = _client({"/notes": PostOnlyLogin}) + + public = client.get("/notes") + protected = client.post("/notes", json={"text": "x"}) + + assert public.status_code == 200 + assert protected.status_code == 401 + + def test_login_route_rejects_get_with_allow_post(self): + client = _client({"/notes": JwtNote}) + + resp = client.get("/auth/login") + + assert resp.status_code == 405 + assert resp.headers["Allow"] == "POST" + + def test_login_with_invalid_utf8_body_returns_422(self): + """Decided in the second pass: this used to be a 500.""" + client = _client({"/notes": JwtNote}) + + resp = client.post( + "/auth/login", + content=b"\xff\xfe", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 422 + assert resp.json()["detail"][0]["loc"] == ["username"] + + def test_backend_is_built_once_per_route_not_per_request(self): + """Decided in the second pass: before, every request built a new backend.""" + built = [] + + class CountingBackend(JWTAuthentication): + @classmethod + def from_config(cls, authentication, login_validator=None): + built.append(cls) + return cls() + + class Counted(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication(backend=CountingBackend) + + client = _client({"/counted": Counted}) + built.clear() + + client.get("/counted") + client.get("/counted") + + assert len(built) == 1 + + def test_permission_subclassing_allowany_does_not_require_login(self): + """Decided in the second pass: the permission says whether login is needed. + + Before, only AllowAny itself (by identity) made a method public.""" + + class Open(AllowAny): + pass + + class OpenNote(RestEndpoint): + text: str = LField(min_length=1) + + class Meta: + authentication = Authentication( + backend=JWTAuthentication, permission=Open + ) + + client = _client({"/open": OpenNote}) + + assert client.get("/open").status_code == 200 + + +class TestClosedSetsAcceptedAsStrings: + def test_mode_sync_string_is_accepted(self): + app = LightApi(engine=_memory_engine(), mode="sync") + app.register({"/items": PlainItem}) + + resp = TestClient(app.build_app()).get("/items") + + assert resp.status_code == 200 + + def test_unknown_mode_raises_configuration_error_with_exact_message(self): + with pytest.raises(ConfigurationError) as exc_info: + LightApi(engine=_memory_engine(), mode="threaded") + + assert str(exc_info.value) == "mode must be 'sync' or 'async', got 'threaded'" + + def test_unknown_pagination_style_raises_with_exact_message(self): + with pytest.raises(ConfigurationError) as exc_info: + Pagination(style="offset") + + assert str(exc_info.value) == ( + "Pagination style 'offset' is invalid. " + "Choose from: ('page_number', 'cursor')" + ) + + def test_unknown_jwt_algorithm_raises_with_exact_message(self): + config = Config(jwt_algorithm="none") + + with pytest.raises(ConfigurationError) as exc_info: + _ = config.jwt_algorithm_value + + assert str(exc_info.value) == ( + "Invalid JWT algorithm 'none'. Valid algorithms are: " + "['ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', " + "'RS256', 'RS384', 'RS512']" + ) + + def test_reflect_full_string_reflects_the_table_like_true(self): + engine = _memory_engine() + meta = MetaData() + Table( + "legacy_full", + meta, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("name", String, nullable=False), + Column("created_at", DateTime, default=datetime.datetime.utcnow), + Column("updated_at", DateTime, default=datetime.datetime.utcnow), + Column("version", Integer, default=1, nullable=False), + ) + meta.create_all(engine) + + class LegacyFull(RestEndpoint): + class Meta: + reflect = "full" + table = "legacy_full" + + app = LightApi(engine=engine) + app.register({"/legacy": LegacyFull}) + client = TestClient(app.build_app()) + + created = client.post("/legacy", json={"name": "old row"}) + + assert created.status_code == 201 + assert created.json()["name"] == "old row" + assert client.get("/legacy").json()["results"][0]["id"] == created.json()["id"] + + def test_unknown_reflect_value_raises_configuration_error(self): + """Decided in the second pass: before, `reflect = "yes"` silently built a + table with only the automatic columns.""" + with pytest.raises(ConfigurationError) as exc_info: + + class Broken(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + reflect = "yes" + + assert str(exc_info.value) == ( + "Meta.reflect must be True, False, 'full' or 'partial', got 'yes'." + ) + + +class TestRequestBodyAndPagination: + def test_post_with_malformed_json_body_returns_422_field_required(self): + client = _client({"/items": PlainItem}) + + resp = client.post( + "/items", content=b"{not json", headers={"Content-Type": "application/json"} + ) + + assert resp.status_code == 422 + assert resp.json()["detail"][0]["loc"] == ["title"] + assert resp.json()["detail"][0]["type"] == "missing" + + def test_put_with_malformed_json_body_returns_422_missing_version(self): + client = _client({"/items": PlainItem}) + pk = client.post("/items", json={"title": "a"}).json()["id"] + + resp = client.put( + f"/items/{pk}", + content=b"{not json", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 422 + assert resp.json() == { + "detail": [{"loc": ["version"], "msg": "Field required", "type": "missing"}] + } + + def test_invalid_cursor_is_ignored_and_first_page_is_returned(self): + client = _client({"/items": CursorItem}) + for label in ("a", "b", "c"): + client.post("/items", json={"label": label}) + + resp = client.get("/items?cursor=%25%25not-a-cursor") + + assert resp.status_code == 200 + assert [row["label"] for row in resp.json()["results"]] == ["a", "b"] + assert resp.json()["next"] is not None + + def test_head_on_detail_route_returns_405_with_allow_header(self): + client = _client({"/items": PlainItem}) + + resp = client.head("/items/1") + + assert resp.status_code == 405 + assert resp.headers["Allow"] == "DELETE, GET, PATCH, PUT" + + +class TestAsyncEngineWithoutMode: + def test_the_engine_type_selects_async_mode(self): + """B6-004: an AsyncEngine with no `mode` used to leave the app in sync mode, + so its tables were never created and every request was a 500 (two 500s in + the differential harness, a "Table creation skipped" warning). The engine + type now selects async mode, as the README promises.""" + + class Plain(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + table = "characterization_plain_async" + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine) + app.register({"/plain": Plain}) + with TestClient(app.build_app()) as client: + response = client.post("/plain", json={"name": "n"}) + + assert response.status_code == 201 + client.portal.call(engine.dispose) + + def test_explicit_sync_mode_with_an_async_engine_is_refused(self): + with pytest.raises(ConfigurationError, match="mode='sync'"): + LightApi( + engine=create_async_engine("sqlite+aiosqlite:///:memory:"), + mode="sync", + ) + + +class Widget(RestEndpoint): + name: str = LField(min_length=1) + + class Meta: + authentication = Authentication(permission=AllowAny) + + +class TestAsyncVersioning: + @pytest_asyncio.fixture + async def async_client(self): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/widgets": Widget}) + async with AsyncClient( + transport=ASGITransport(app=app.build_app()), base_url="http://test" + ) as client: + yield client + await engine.dispose() + + async def test_async_patch_with_stale_version_returns_409(self, async_client): + pk = (await async_client.post("/widgets", json={"name": "w"})).json()["id"] + + resp = await async_client.patch( + f"/widgets/{pk}", json={"name": "w2", "version": 999} + ) + + assert resp.status_code == 409 + assert resp.json() == {"detail": "version conflict"} + + async def test_async_patch_without_version_returns_422(self, async_client): + pk = (await async_client.post("/widgets", json={"name": "w"})).json()["id"] + + resp = await async_client.patch(f"/widgets/{pk}", json={"name": "w2"}) + + assert resp.status_code == 422 + assert resp.json() == { + "detail": [{"loc": ["version"], "msg": "Field required", "type": "missing"}] + } + + +class _RedisDown: + """A redis client whose every call fails the way a dead server does.""" + + def _fail(self, *args, **kwargs): + raise redis.ConnectionError("Error 61 connecting to localhost:6379") + + get = setex = delete = scan_iter = ping = _fail + + +class TestCacheWithRedisDown: + @pytest.fixture(autouse=True) + def fresh_redis_client(self): + cache_module.RedisCacheBackend.shared.cache_clear() + yield + cache_module.RedisCacheBackend.shared.cache_clear() + + def test_cached_get_and_write_succeed_when_redis_connection_fails(self): + with patch("lightapi.cache.redis.from_url", return_value=_RedisDown()): + with pytest.warns(RuntimeWarning, match="not reachable"): + client = _client({"/cached": CachedItem}) + + created = client.post("/cached", json={"name": "n"}) + listed = client.get("/cached") + listed_again = client.get("/cached") + + assert created.status_code == 201 + assert listed.status_code == 200 + assert listed_again.json()["results"][0]["name"] == "n" diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 57fcbf3..9438265 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -1,5 +1,7 @@ """Tests for all four Serializer forms and validation guards.""" +from typing import ClassVar + import pytest from sqlalchemy import create_engine from sqlalchemy.pool import StaticPool @@ -9,20 +11,20 @@ from lightapi.config import Serializer from lightapi.exceptions import ConfigurationError from lightapi.fields import Field as LField -from lightapi.schema import normalise_serializer +from lightapi.schema import FieldProjection class TestSerializerForm1: def test_no_args_returns_all_none(self): s = Serializer() - f, r, w = normalise_serializer(s) + f, r, w = FieldProjection.from_serializer(s) assert f is None and r is None and w is None class TestSerializerForm2: def test_fields_subset(self): s = Serializer(fields=["id", "name"]) - f, r, w = normalise_serializer(s) + f, r, w = FieldProjection.from_serializer(s) assert f == ["id", "name"] assert r is None and w is None @@ -38,7 +40,7 @@ def test_fields_and_write_raises(self): class TestSerializerForm3: def test_per_verb(self): s = Serializer(read=["id", "name", "tag"], write=["id", "name"]) - f, r, w = normalise_serializer(s) + f, r, w = FieldProjection.from_serializer(s) assert f is None assert r == ["id", "name", "tag"] assert w == ["id", "name"] @@ -47,39 +49,39 @@ def test_per_verb(self): class TestSerializerForm4: def test_subclass_instance_form(self): class PubSerializer(Serializer): - read = ["id", "name", "created_at"] - write = ["id", "name"] + read: ClassVar[list[str]] = ["id", "name", "created_at"] + write: ClassVar[list[str]] = ["id", "name"] - f, r, w = normalise_serializer(PubSerializer) + f, r, w = FieldProjection.from_serializer(PubSerializer) assert f is None assert r == ["id", "name", "created_at"] assert w == ["id", "name"] def test_subclass_fields_only(self): class AuditSer(Serializer): - fields = ["id", "created_at", "updated_at"] + fields: ClassVar[list[str]] = ["id", "created_at", "updated_at"] - f, r, w = normalise_serializer(AuditSer) + f, _r, _w = FieldProjection.from_serializer(AuditSer) assert f == ["id", "created_at", "updated_at"] def test_subclass_defines_fields_and_read_raises_at_class_load(self): with pytest.raises(ConfigurationError): class BadSerializer(Serializer): - fields = ["id"] - read = ["id", "name"] + fields: ClassVar[list[str]] = ["id"] + read: ClassVar[list[str]] = ["id", "name"] def test_empty_subclass_returns_all_none(self): class EmptySer(Serializer): pass - f, r, w = normalise_serializer(EmptySer) + f, r, w = FieldProjection.from_serializer(EmptySer) assert f is None and r is None and w is None def test_shared_subclass_reused_on_two_endpoints(self): class SharedSer(Serializer): - read = ["id", "name"] - write = ["id"] + read: ClassVar[list[str]] = ["id", "name"] + write: ClassVar[list[str]] = ["id"] class Ep1(RestEndpoint): name: str = LField(min_length=1) @@ -93,8 +95,8 @@ class Ep2(RestEndpoint): class Meta: serializer = SharedSer - f1, r1, w1 = Ep1._meta["serializer_normalised"] - f2, r2, w2 = Ep2._meta["serializer_normalised"] + _f1, r1, w1 = Ep1._meta["serializer_normalised"] + _f2, r2, w2 = Ep2._meta["serializer_normalised"] assert r1 == r2 == ["id", "name"] assert w1 == w2 == ["id"] @@ -104,14 +106,14 @@ def test_base_model_subclass_raises(self): from pydantic import BaseModel with pytest.raises(ConfigurationError): - normalise_serializer(BaseModel) + FieldProjection.from_serializer(BaseModel) def test_non_serializer_class_raises(self): with pytest.raises(ConfigurationError): - normalise_serializer(int) + FieldProjection.from_serializer(int) def test_none_returns_all_none(self): - assert normalise_serializer(None) == (None, None, None) + assert FieldProjection.from_serializer(None) == (None, None, None) class TestSerializerPipelineIntegration: @@ -150,7 +152,7 @@ class Meta: def test_per_verb_serializer_post_only_write_fields(self): """POST 201 response has only fields in Serializer(write=[...]).""" - class OrderEndpoint(RestEndpoint): + class OrderWriteEndpoint(RestEndpoint): name: str = LField(min_length=1) tag: str = LField(default="") @@ -166,8 +168,8 @@ class Meta: poolclass=StaticPool, ) app = LightApi(engine=engine) - app.register({"/orders": OrderEndpoint}) - OrderEndpoint.__table__.create(engine, checkfirst=True) + app.register({"/orders": OrderWriteEndpoint}) + OrderWriteEndpoint.__table__.create(engine, checkfirst=True) c = TestClient(app.build_app()) resp = c.post("/orders", json={"name": "Order1", "tag": "x"}) assert resp.status_code == 201 diff --git a/tests/test_server_command.py b/tests/test_server_command.py index dcbd06f..ecca395 100644 --- a/tests/test_server_command.py +++ b/tests/test_server_command.py @@ -13,7 +13,7 @@ def test_from_env_requires_config() -> None: def test_from_env_applies_defaults() -> None: cmd = ServerCommand.from_env({"LIGHTAPI_CONFIG": "/etc/lightapi/lightapi.yaml"}) - assert cmd._host == "0.0.0.0" + assert cmd._host == "0.0.0.0" # noqa: S104 the default under test assert cmd._port == 8000 diff --git a/tests/test_server_lifecycle.py b/tests/test_server_lifecycle.py new file mode 100644 index 0000000..e0904e8 --- /dev/null +++ b/tests/test_server_lifecycle.py @@ -0,0 +1,149 @@ +"""T4-05, T4-06, T4-07: debug mode, both CORS paths, and the login rate limit.""" + +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +from lightapi import Authentication, JWTAuthentication, LightApi, RestEndpoint +from lightapi.core import CORSMiddleware +from lightapi.fields import Field +from lightapi.rate_limiter import RateLimiter + + +def _memory_engine(): + return create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + + +class BoomEndpoint(RestEndpoint): + name: str = Field(min_length=1) + + def get(self, request): + raise RuntimeError("boom") + + +class T405Item(RestEndpoint): + name: str = Field(min_length=1) + + +def test_debug_true_returns_a_traceback_page(): + app = LightApi(engine=_memory_engine()) + app.register({"/t405boom": BoomEndpoint}) + client = TestClient(app.build_app(debug=True), raise_server_exceptions=False) + + response = client.get("/t405boom") + + assert response.status_code == 500 + assert "RuntimeError" in response.text + assert "boom" in response.text + + +def test_debug_false_returns_a_plain_500(): + app = LightApi(engine=_memory_engine()) + app.register({"/t405boom2": BoomEndpoint}) + client = TestClient(app.build_app(debug=False), raise_server_exceptions=False) + + response = client.get("/t405boom2") + + assert response.status_code == 500 + assert "RuntimeError" not in response.text + assert "Traceback" not in response.text + + +class T406Cors(RestEndpoint): + name: str = Field(min_length=1) + + +def test_cors_via_constructor_option(): + app = LightApi(engine=_memory_engine(), cors_origins=["https://app.example.com"]) + app.register({"/t406a": T406Cors}) + client = TestClient(app.build_app()) + + preflight = client.options( + "/t406a", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "POST", + }, + ) + normal = client.get("/t406a", headers={"Origin": "https://app.example.com"}) + + assert preflight.headers["access-control-allow-origin"] == "https://app.example.com" + assert normal.headers["access-control-allow-origin"] == "https://app.example.com" + + +class T406Own(RestEndpoint): + name: str = Field(min_length=1) + + +def test_cors_via_own_core_middleware_adds_headers_to_allowed_methods(): + """lightapi.core.CORSMiddleware runs inside EndpointHandler, which Starlette + only reaches for the endpoint's allowed verbs. Its own OPTIONS branch is + documented nowhere and is unreachable this way: OPTIONS is not in the + Route's methods, so Starlette's router answers 405 before any middleware + on the endpoint runs (this is the mechanism the docs use for cors_origins + instead: it wraps the whole ASGI app, outside routing).""" + app = LightApi(engine=_memory_engine(), middlewares=[CORSMiddleware]) + app.register({"/t406b": T406Own}) + client = TestClient(app.build_app()) + + preflight = client.options( + "/t406b", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "POST", + }, + ) + normal = client.post("/t406b", json={"name": "x"}) + + assert preflight.status_code == 405 + assert normal.headers["access-control-allow-origin"] == "*" + + +class T407Secret(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + authentication = Authentication(JWTAuthentication) + + +def test_login_rate_limit_returns_429_with_headers(monkeypatch): + monkeypatch.setenv("LIGHTAPI_JWT_SECRET", "t407-secret") + + def validator(username, password): + return {"sub": username} if password == "right" else None + + app = LightApi( + engine=_memory_engine(), + login_validator=validator, + rate_limiter=RateLimiter(requests_per_minute=3), + ) + app.register({"/t407secrets": T407Secret}) + client = TestClient(app.build_app()) + body = {"username": "a", "password": "right"} + + responses = [client.post("/auth/login", json=body) for _ in range(4)] + + assert [r.status_code for r in responses] == [200, 200, 200, 429] + limited = responses[-1] + assert "detail" in limited.json() + assert limited.headers.get("retry-after") is not None + + +def test_default_rate_limit_is_generous_for_normal_use(monkeypatch): + monkeypatch.setenv("LIGHTAPI_JWT_SECRET", "t407-defaults-secret") + + def validator(username, password): + return {"sub": username} + + app = LightApi(engine=_memory_engine(), login_validator=validator) + app.register({"/t407defaults": T407Secret}) + client = TestClient(app.build_app()) + + responses = [ + client.post("/auth/login", json={"username": "a", "password": "x"}) + for _ in range(5) + ] + + assert all(r.status_code == 200 for r in responses) diff --git a/tests/test_sync_offload.py b/tests/test_sync_offload.py new file mode 100644 index 0000000..ac1f156 --- /dev/null +++ b/tests/test_sync_offload.py @@ -0,0 +1,77 @@ +"""Sync CRUD must not run on the event loop thread, except on single-connection pools.""" + +import threading + +import pytest +from sqlalchemy import create_engine +from sqlalchemy import select as sa_select +from sqlalchemy.pool import StaticPool +from starlette.requests import Request +from starlette.responses import Response +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint +from lightapi.core import Middleware +from lightapi.fields import Field as LField + +threads: dict[str, int] = {} + + +class RecordLoopThread(Middleware): + async def process( + self, request: Request, response: Response | None + ) -> Response | None: + if response is None: + threads["loop"] = threading.get_ident() + return response + + +class PooledProbe(RestEndpoint): + label: str = LField(min_length=1) + + def queryset(self, request: Request): + threads["crud"] = threading.get_ident() + return sa_select(type(self)._model_class) + + +class StaticProbe(RestEndpoint): + label: str = LField(min_length=1) + + def queryset(self, request: Request): + threads["crud"] = threading.get_ident() + return sa_select(type(self)._model_class) + + +@pytest.fixture(autouse=True) +def reset_threads(): + threads.clear() + + +def _client(engine, route: str, endpoint: type) -> TestClient: + app = LightApi(engine=engine, middlewares=[RecordLoopThread]) + app.register({route: endpoint}) + return TestClient(app.build_app()) + + +def test_sync_list_on_pooled_engine_runs_off_the_event_loop_thread(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'offload.db'}") + client = _client(engine, "/pooledprobes", PooledProbe) + + response = client.get("/pooledprobes") + + assert response.status_code == 200 + assert threads["crud"] != threads["loop"] + + +def test_sync_list_on_static_pool_stays_on_the_event_loop_thread(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + client = _client(engine, "/staticprobes", StaticProbe) + + response = client.get("/staticprobes") + + assert response.status_code == 200 + assert threads["crud"] == threads["loop"] diff --git a/tests/test_sync_verb_override.py b/tests/test_sync_verb_override.py new file mode 100644 index 0000000..0a064f1 --- /dev/null +++ b/tests/test_sync_verb_override.py @@ -0,0 +1,201 @@ +"""A plain `def get/post/...` override must be served, as the tutorial teaches.""" + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from starlette.requests import Request +from starlette.testclient import TestClient + +from lightapi import LightApi, RestEndpoint +from lightapi.fields import Field as LField + + +class GreetingEndpoint(RestEndpoint): + name: str = LField(min_length=1) + + def get(self, request: Request): + return {"message": "custom sync get"} + + +class DecoratedListEndpoint(RestEndpoint): + name: str = LField(min_length=1) + + def get(self, request: Request): + response = self.list(request) + response.headers["X-Served-By"] = "sync-override" + return response + + +class AsyncEngineSyncOverrideEndpoint(RestEndpoint): + name: str = LField(min_length=1) + + def get(self, request: Request): + response = self.list(request) + response.headers["X-Served-By"] = "sync-override" + return response + + +class BlogEntryEndpoint(RestEndpoint): + """`post` here is a column, not a verb override.""" + + post: str = LField(min_length=1) + + +class AllVerbsEndpoint(RestEndpoint): + """A sync override for every verb, each stamping a header so the test can + tell the override ran rather than the built-in method.""" + + name: str = LField(min_length=1) + + def post(self, request: Request): + response = self.create({"name": "from-post-override"}) + response.headers["X-Served-By"] = "post-override" + return response + + def put(self, request: Request): + pk = request.path_params["id"] + response = self.update({"name": "from-put-override", "version": 1}, pk) + response.headers["X-Served-By"] = "put-override" + return response + + def patch(self, request: Request): + pk = request.path_params["id"] + response = self.update( + {"name": "from-patch-override", "version": 1}, pk, partial=True + ) + response.headers["X-Served-By"] = "patch-override" + return response + + def delete(self, request: Request): + pk = request.path_params["id"] + response = self.destroy(request, pk) + response.headers["X-Served-By"] = "delete-override" + return response + + +class DetailGetOverrideEndpoint(RestEndpoint): + """A sync `def get` override also intercepts the detail route (/x/{id}).""" + + name: str = LField(min_length=1) + + def get(self, request: Request): + pk = request.path_params.get("id") + if pk is None: + return self.list(request) + response = self.retrieve(request, pk) + response.headers["X-Served-By"] = "get-override" + return response + + +@pytest.fixture +def sync_client(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + app = LightApi(engine=engine) + app.register( + { + "/greetings": GreetingEndpoint, + "/decorated": DecoratedListEndpoint, + "/blogentries": BlogEntryEndpoint, + "/allverbs": AllVerbsEndpoint, + "/detailget": DetailGetOverrideEndpoint, + } + ) + return TestClient(app.build_app()) + + +@pytest_asyncio.fixture +async def async_client(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + app = LightApi(engine=engine, mode="async") + app.register({"/asyncsyncoverride": AsyncEngineSyncOverrideEndpoint}) + async with AsyncClient( + transport=ASGITransport(app=app.build_app()), base_url="http://test" + ) as client: + yield client + await engine.dispose() + + +def test_sync_get_override_returning_dict_is_served(sync_client): + response = sync_client.get("/greetings") + + assert response.status_code == 200 + assert response.json() == {"message": "custom sync get"} + + +def test_sync_get_override_can_call_the_builtin_list(sync_client): + response = sync_client.get("/decorated") + + assert response.status_code == 200 + assert response.headers["X-Served-By"] == "sync-override" + assert response.json() == {"results": []} + + +def test_column_named_like_a_verb_is_not_treated_as_an_override(sync_client): + created = sync_client.post("/blogentries", json={"post": "hello"}) + + assert created.status_code == 201 + assert created.json()["post"] == "hello" + + +async def test_sync_get_override_on_async_engine_can_call_the_builtin_list( + async_client, +): + response = await async_client.get("/asyncsyncoverride") + + assert response.status_code == 200 + assert response.headers["X-Served-By"] == "sync-override" + assert response.json() == {"results": []} + + +def test_sync_post_override_is_served(sync_client): + response = sync_client.post("/allverbs", json={"name": "ignored"}) + + assert response.status_code == 201 + assert response.headers["X-Served-By"] == "post-override" + assert response.json()["name"] == "from-post-override" + + +def test_sync_put_override_is_served(sync_client): + sync_client.post("/allverbs", json={"name": "original"}) + + response = sync_client.put("/allverbs/1", json={"name": "ignored", "version": 1}) + + assert response.status_code == 200 + assert response.headers["X-Served-By"] == "put-override" + assert response.json()["name"] == "from-put-override" + + +def test_sync_patch_override_is_served(sync_client): + sync_client.post("/allverbs", json={"name": "original"}) + + response = sync_client.patch("/allverbs/1", json={"version": 1}) + + assert response.status_code == 200 + assert response.headers["X-Served-By"] == "patch-override" + assert response.json()["name"] == "from-patch-override" + + +def test_sync_delete_override_is_served(sync_client): + sync_client.post("/allverbs", json={"name": "original"}) + + response = sync_client.delete("/allverbs/1") + + assert response.status_code == 204 + assert response.headers["X-Served-By"] == "delete-override" + + +def test_sync_get_override_also_intercepts_the_detail_route(sync_client): + created = sync_client.post("/detailget", json={"name": "x"}).json() + + response = sync_client.get(f"/detailget/{created['id']}") + + assert response.status_code == 200 + assert response.headers["X-Served-By"] == "get-override" + assert response.json()["name"] == "x" diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index a96b914..625da00 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -3,6 +3,7 @@ import os import tempfile import textwrap +from pathlib import Path import pytest import yaml @@ -10,7 +11,7 @@ from lightapi.exceptions import ConfigurationError from lightapi.lightapi import LightApi -from lightapi.yaml_loader import LightAPIConfig, _resolve_name +from lightapi.yaml_config import LightAPIConfig # ───────────────────────────────────────────────────────────────────────────── # Helpers @@ -47,7 +48,7 @@ def _from_str(content: str, login_validator=None) -> LightApi: kwargs["login_validator"] = _default_login_validator return LightApi.from_config(path, **kwargs) finally: - os.unlink(path) + Path(path).unlink() # ───────────────────────────────────────────────────────────────────────────── @@ -59,10 +60,10 @@ class TestSchemaValidation: def test_invalid_yaml_syntax_raises(self): path = _write_yaml("invalid: yaml: content: [") try: - with pytest.raises(Exception): + with pytest.raises(yaml.YAMLError): LightApi.from_config(path) finally: - os.unlink(path) + Path(path).unlink() def test_endpoint_without_route_raises(self): # Direct model_validate raises pydantic ValidationError; @@ -85,7 +86,7 @@ def test_missing_env_var_raises(self): with pytest.raises(ConfigurationError, match="LIGHTAPI_DB_MISSING_XTEST"): LightApi.from_config(path) finally: - os.unlink(path) + Path(path).unlink() # ───────────────────────────────────────────────────────────────────────────── @@ -101,12 +102,10 @@ def _route_cls(self, app: LightApi, route_path: str) -> type: return app._endpoint_map[route_path] def test_nested_database_block(self): - app = _from_str( - """\ + app = _from_str("""\ database: url: "sqlite:///:memory:" - """ - ) + """) assert app is not None def test_dynamic_fields_create_endpoint_class(self): @@ -267,15 +266,10 @@ def test_reflect_endpoint_no_fields_needed(self): We test the class builder directly because register() would attempt actual DB table inspection (requires a running engine). """ - from lightapi.yaml_loader import ( - DefaultsConfig, - EndpointConfig, - MetaConfig, - _build_endpoint_class, - ) + from lightapi.yaml_config import DefaultsConfig, EndpointConfig, MetaConfig entry = EndpointConfig(route="/legacy", reflect=True, meta=MetaConfig()) - cls = _build_endpoint_class(entry, DefaultsConfig()) + cls = entry.build_endpoint_class(DefaultsConfig()) assert cls.Meta.reflect is True def test_middleware_resolved_by_name(self): @@ -312,7 +306,7 @@ def test_from_config_kwargs_override_yaml(self): app = LightApi.from_config(path, engine=custom_engine) assert app._engine is custom_engine finally: - os.unlink(path) + Path(path).unlink() # ───────────────────────────────────────────────────────────────────────────── @@ -334,7 +328,7 @@ def test_auth_path_from_yaml(self): backend: JWTAuthentication permission: IsAuthenticated endpoints: - - route: /x + - route: /xauthpath fields: data: { type: str } meta: @@ -365,7 +359,7 @@ def test_validator(username: str, password: str): backend: JWTAuthentication permission: IsAuthenticated endpoints: - - route: /x + - route: /xvalidator fields: data: { type: str } meta: @@ -385,14 +379,14 @@ def test_jwt_expiration_from_defaults(self): permission: IsAuthenticated jwt_expiration: 300 endpoints: - - route: /x + - route: /xexpiration fields: data: { type: str } meta: methods: [GET] """ app = _from_str(content) - cls = app._endpoint_map["/x"] + cls = app._endpoint_map["/xexpiration"] assert cls.Meta.authentication.jwt_expiration == 300 def test_jwt_extra_claims_from_defaults(self): @@ -406,14 +400,14 @@ def test_jwt_extra_claims_from_defaults(self): permission: IsAuthenticated jwt_extra_claims: [sub, email] endpoints: - - route: /x + - route: /xclaims fields: data: { type: str } meta: methods: [GET] """ app = _from_str(content) - cls = app._endpoint_map["/x"] + cls = app._endpoint_map["/xclaims"] assert cls.Meta.authentication.jwt_extra_claims == ["sub", "email"] def test_basic_authentication_from_yaml(self): @@ -426,7 +420,7 @@ def test_basic_authentication_from_yaml(self): backend: BasicAuthentication permission: IsAuthenticated endpoints: - - route: /items + - route: /basicitems fields: name: { type: str } meta: @@ -435,7 +429,7 @@ def test_basic_authentication_from_yaml(self): app = _from_str(content) from lightapi.auth import BasicAuthentication - cls = app._endpoint_map["/items"] + cls = app._endpoint_map["/basicitems"] assert cls.Meta.authentication.backend is BasicAuthentication def test_login_validator_invalid_dotted_path_raises(self): @@ -462,7 +456,7 @@ def test_login_validator_invalid_dotted_path_raises(self): with pytest.raises(ConfigurationError, match="login_validator"): LightApi.from_config(path) finally: - os.unlink(path) + Path(path).unlink() # ───────────────────────────────────────────────────────────────────────────── @@ -474,18 +468,18 @@ class TestResolveName: def test_builtin_names_resolve(self): from lightapi.auth import IsAuthenticated - assert _resolve_name("IsAuthenticated") is IsAuthenticated + assert LightAPIConfig.resolve_name("IsAuthenticated") is IsAuthenticated def test_dotted_path_resolves(self): - cls = _resolve_name("lightapi.auth.AllowAny") + cls = LightAPIConfig.resolve_name("lightapi.auth.AllowAny") from lightapi.auth import AllowAny assert cls is AllowAny def test_unknown_name_raises(self): with pytest.raises(ConfigurationError, match="Unknown class name"): - _resolve_name("CompletlyUnknownClass") + LightAPIConfig.resolve_name("CompletlyUnknownClass") def test_bad_dotted_path_raises(self): with pytest.raises(ConfigurationError): - _resolve_name("non.existent.module.Foo") + LightAPIConfig.resolve_name("non.existent.module.Foo") diff --git a/tests/test_yaml_plain_field.py b/tests/test_yaml_plain_field.py index f104453..79037d4 100644 --- a/tests/test_yaml_plain_field.py +++ b/tests/test_yaml_plain_field.py @@ -14,9 +14,10 @@ def test_constraintless_field_is_persisted(tmp_path) -> None: config = tmp_path / "lightapi.yaml" - config.write_text(textwrap.dedent(f""" + config.write_text( + textwrap.dedent(f""" database: - url: "sqlite:///{tmp_path / 'plain.db'}" + url: "sqlite:///{tmp_path / "plain.db"}" endpoints: - route: /products fields: @@ -25,7 +26,8 @@ def test_constraintless_field_is_persisted(tmp_path) -> None: meta: methods: [GET, POST] authentication: {{ permission: AllowAny }} - """)) + """) + ) app = LightApi.from_config(str(config)) client = TestClient(app.build_app()) diff --git a/tools/differential/compare.py b/tools/differential/compare.py new file mode 100644 index 0000000..58836b8 --- /dev/null +++ b/tools/differential/compare.py @@ -0,0 +1,86 @@ +"""Compare two recordings made by record.py. + +Exit status 0 means every difference is one the CHANGELOG declares. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +# step id (or prefix ending in "/") -> why the two versions are allowed to differ +ALLOWED = { + "changes/sync-override-": ( + "A plain `def get/post` override used to be ignored; it is served now." + ), + "changes/login-with-subclass-backend": ( + "/auth/login now calls validate_credentials() of the endpoint's backend." + ), + "cache-async/": ( + "Meta.cache was ignored on async engines; GETs are cached and writes " + "invalidate now." + ), +} + + +def _reason(step_id: str) -> str | None: + for prefix, reason in ALLOWED.items(): + if step_id == prefix or step_id.startswith(prefix): + return reason + return None + + +def _load(path: str) -> tuple[str, dict[str, Any]]: + recording = json.loads(Path(path).read_text(encoding="utf-8")) + steps = {step["id"]: step for step in recording["steps"]} + if len(steps) != len(recording["steps"]): + raise SystemExit(f"{path}: step ids are not unique") + label = f"{recording['lightapi']}, source {recording.get('fingerprint', '?')}" + return label, steps + + +def _describe(step: dict[str, Any] | None) -> str: + if step is None: + return " (step missing)" + if "value" in step: + return f" value: {step['value']}" + return ( + f" status: {step['status']}\n" + f" headers: {step['headers']}\n" + f" body: {step['body'][:600]}" + ) + + +def main(path_a: str, path_b: str) -> int: + version_a, steps_a = _load(path_a) + version_b, steps_b = _load(path_b) + same, allowed, unexpected = 0, [], [] + for step_id in sorted(steps_a.keys() | steps_b.keys()): + a, b = steps_a.get(step_id), steps_b.get(step_id) + if a == b: + same += 1 + elif _reason(step_id): + allowed.append(step_id) + else: + unexpected.append(step_id) + + print(f"A = lightapi {version_a} ({len(steps_a)} steps)") + print(f"B = lightapi {version_b} ({len(steps_b)} steps)") + print(f"identical: {same}") + print(f"different, declared in the CHANGELOG: {len(allowed)}") + for step_id in allowed: + print(f" {step_id}: {_reason(step_id)}") + print(f"different, NOT declared: {len(unexpected)}") + for step_id in unexpected: + print(f"\n {step_id}") + print(" A:") + print(_describe(steps_a.get(step_id))) + print(" B:") + print(_describe(steps_b.get(step_id))) + return 1 if unexpected else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/tools/differential/diff_validators.py b/tools/differential/diff_validators.py new file mode 100644 index 0000000..7f434e9 --- /dev/null +++ b/tools/differential/diff_validators.py @@ -0,0 +1,41 @@ +"""Callables that the YAML scenarios reference by dotted path.""" + +from __future__ import annotations + +from typing import Any + +from lightapi import Middleware + +USERS = { + "alice": {"password": "secret", "sub": "alice", "is_admin": False, "role": "user"}, + "root": {"password": "toor", "sub": "root", "is_admin": True, "role": "admin"}, +} + + +def check_login(username: str, password: str) -> dict[str, Any] | None: + user = USERS.get(username) + if user is None or user["password"] != password: + return None + return {key: value for key, value in user.items() if key != "password"} + + +def exploding_login(username: str, password: str) -> dict[str, Any] | None: + raise RuntimeError("directory server is down") + + +async def async_login(username: str, password: str) -> dict[str, Any] | None: + return None + + +def one_argument_login(username: str) -> None: + return None + + +NOT_CALLABLE = "just a string" + + +class StampMiddleware(Middleware): + def process(self, request: Any, response: Any) -> Any: + if response is not None: + response.headers["x-trace"] = "stamped" + return response diff --git a/tools/differential/harness.py b/tools/differential/harness.py new file mode 100644 index 0000000..cc8bb07 --- /dev/null +++ b/tools/differential/harness.py @@ -0,0 +1,125 @@ +"""Shared pieces of the differential harness: recorder, clients, fake Redis. + +Everything here must run unchanged on the oldest version being compared, so it +only uses API that the README documents. +""" + +from __future__ import annotations + +import re +from typing import Any + +from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool +from starlette.testclient import TestClient + +JWT_SECRET = "differential-harness-secret" + +# The only things allowed to differ between two runs of the same code. +_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?") +_JWT = re.compile(r"eyJ[\w-]+\.[\w-]+\.[\w-]+") + +_KEPT_HEADERS = ( + "content-type", + "allow", + "www-authenticate", + "retry-after", + "location", + "x-trace", +) +_KEPT_HEADER_PREFIXES = ("access-control-", "x-ratelimit-") + + +def normalise(text: str) -> str: + return _JWT.sub("", _TIMESTAMP.sub("", text)) + + +def _stable_header(name: str, value: str) -> str: + # Starlette builds Allow from a set, so its order changes with the process + # hash seed; the reset header is wall-clock time. + if name == "allow": + return ", ".join(sorted(part.strip() for part in value.split(","))) + if name == "x-ratelimit-reset": + return "" + return normalise(value) + + +class Recorder: + """Collects what each scenario step observed, in order.""" + + def __init__(self) -> None: + self.steps: list[dict[str, Any]] = [] + + def http(self, step_id: str, response: Any) -> Any: + headers = { + name: _stable_header(name, value) + for name, value in sorted(response.headers.items()) + if name in _KEPT_HEADERS or name.startswith(_KEPT_HEADER_PREFIXES) + } + self.steps.append( + { + "id": step_id, + "status": int(response.status_code), + "headers": headers, + # Raw text, not parsed JSON: key order is part of the contract. + "body": normalise(response.text), + } + ) + return response + + def note(self, step_id: str, value: Any) -> None: + self.steps.append({"id": step_id, "value": value}) + + def raised(self, step_id: str, error: BaseException) -> None: + self.note(step_id, f"{type(error).__name__}: {normalise(str(error))}") + + +class FakeRedis: + """The five calls lightapi makes on a Redis client, backed by a dict.""" + + def __init__(self) -> None: + self.store: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self.store.get(key) + + def setex(self, key: str, ttl: int, value: str) -> bool: + self.store[key] = value + return True + + def delete(self, *keys: str) -> int: + return sum(self.store.pop(key, None) is not None for key in keys) + + def scan_iter(self, pattern: str) -> list[str]: + prefix = pattern.rstrip("*") + return [key for key in list(self.store) if key.startswith(prefix)] + + def ping(self) -> bool: + return True + + +def install_fake_redis() -> FakeRedis: + """Make every Redis client lightapi builds from now on the same FakeRedis.""" + import redis + + fake = FakeRedis() + redis.from_url = lambda *args, **kwargs: fake # type: ignore[assignment] + return fake + + +def memory_engine() -> Any: + return create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + + +def client_for(app: Any) -> TestClient: + # A 500 is recorded like any other response instead of aborting the run. + return TestClient(app.build_app(), raise_server_exceptions=False) + + +def bearer(payload: dict[str, Any], expiration: int | None = None) -> dict[str, str]: + from lightapi import JWTAuthentication + + token = JWTAuthentication().generate_token(payload, expiration=expiration) + return {"Authorization": f"Bearer {token}"} diff --git a/tools/differential/record.py b/tools/differential/record.py new file mode 100644 index 0000000..5c25da5 --- /dev/null +++ b/tools/differential/record.py @@ -0,0 +1,91 @@ +"""Run every scenario against whichever lightapi is installed and save what it did. + +Install each version into a venv of its own. Do NOT use `uv run --with +`: a rebuilt wheel keeps its file name and version, and uv then +reuses the environment it made for the previous build, so the run silently +tests old code. + + # A: the published version + uv venv /tmp/la-a --python 3.12 + uv pip install -p /tmp/la-a "lightapi[async]==0.1.29" httpx + /tmp/la-a/bin/python tools/differential/record.py /tmp/run-a.json + + # B: a wheel built from the working tree + uv build + uv venv /tmp/la-b --python 3.12 + uv pip install -p /tmp/la-b --no-cache --reinstall \\ + "lightapi[async] @ file://$PWD/dist/lightapi-0.1.27-py3-none-any.whl" httpx + /tmp/la-b/bin/python tools/differential/record.py /tmp/run-b.json + + python tools/differential/compare.py /tmp/run-a.json /tmp/run-b.json + +Recording the same version twice must give identical files. Do that first: +it is what proves a later difference comes from the code and not from the run. +Each recording carries a fingerprint of the installed source; if B's +fingerprint did not change after a rebuild, B is stale. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import sys +import tempfile +import warnings +from importlib import metadata +from pathlib import Path + +from harness import JWT_SECRET, Recorder, install_fake_redis + + +def source_fingerprint(package_dir: Path) -> str: + digest = hashlib.sha256() + for source in sorted(package_dir.rglob("*.py")): + digest.update(source.relative_to(package_dir).as_posix().encode()) + digest.update(source.read_bytes()) + return digest.hexdigest()[:12] + + +def main(output: str) -> None: + warnings.simplefilter("ignore") + os.environ["LIGHTAPI_JWT_SECRET"] = JWT_SECRET + fake_redis = install_fake_redis() + + import scenarios_more + import scenarios_sync + + import lightapi + + package_dir = Path(lightapi.__file__).resolve().parent + repo = Path(__file__).resolve().parents[2] + if repo in package_dir.parents: + raise SystemExit( + "lightapi was imported from the working tree. Install a wheel or a " + "release instead, so the run tests what users would get." + ) + + rec = Recorder() + with tempfile.TemporaryDirectory() as tmp_dir: + scenarios_sync.run(rec, tmp_dir, fake_redis) + scenarios_more.yaml_config(rec, tmp_dir) + scenarios_more.declared_changes(rec) + asyncio.run(scenarios_more.async_engine_scenarios(rec, fake_redis)) + # Last, so it sees every table the scenarios above created. + scenarios_sync.schema_ddl(rec) + + recording = { + "lightapi": metadata.version("lightapi"), + "fingerprint": source_fingerprint(package_dir), + "steps": rec.steps, + } + Path(output).write_text(json.dumps(recording, indent=1), encoding="utf-8") + print( + f"lightapi {recording['lightapi']} (source {recording['fingerprint']}): " + f"{len(rec.steps)} steps -> {output}" + ) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/tools/differential/scenarios_more.py b/tools/differential/scenarios_more.py new file mode 100644 index 0000000..3b08a1f --- /dev/null +++ b/tools/differential/scenarios_more.py @@ -0,0 +1,464 @@ +"""YAML, async-engine and declared-behaviour-change scenarios.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from typing import Any + +import httpx +from diff_validators import check_login +from harness import Recorder, bearer, client_for, memory_engine +from scenarios_sync import ADMIN, USER, Step, book_endpoint, crud_steps, play +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import create_async_engine + +from lightapi import ( + Authentication, + Cache, + Field, + FieldFilter, + Filtering, + IsAuthenticated, + JWTAuthentication, + LightApi, + OrderingFilter, + Pagination, + RestEndpoint, +) + +VALID_YAML = """ +defaults: + authentication: { backend: JWTAuthentication, permission: IsAuthenticated } + pagination: { style: page_number, page_size: 2 } +auth: + auth_path: /auth + login_validator: diff_validators.check_login +middleware: [diff_validators.StampMiddleware] +endpoints: + - route: /dfy-announcements + fields: + title: { type: str, min_length: 1, max_length: 20 } + pinned: { type: bool, default: false } + score: { type: int, optional: true } + meta: + methods: [GET, POST] + authentication: { permission: AllowAny } + filtering: { fields: [pinned], ordering: [title] } + - route: /dfy-tickets + fields: + subject: { type: str, min_length: 1 } + cost: { type: Decimal, decimal_places: 2, optional: true } + meta: + methods: + GET: { authentication: { permission: AllowAny } } + POST: { authentication: { permission: IsAuthenticated } } + DELETE: { authentication: { permission: IsAdminUser } } + - route: /api/v1/dfy-things + fields: + name: { type: str } + meta: + serializer: { fields: [id, name] } + pagination: { style: cursor, page_size: 2 } +""" + +INVALID_YAML = { + "unknown-field-type": """ + endpoints: + - route: /dfy-bad1 + fields: { a: { type: colour } } + """, + "unknown-class-name": """ + endpoints: + - route: /dfy-bad2 + fields: { a: { type: str } } + meta: { authentication: { backend: NoSuchBackend } } + """, + "unknown-dotted-class": """ + middleware: [no.such.module.Thing] + endpoints: [] + """, + "unknown-http-method": """ + endpoints: + - route: /dfy-bad3 + fields: { a: { type: str } } + meta: { methods: [GET, TELEPORT] } + """, + "validator-not-dotted": """ + auth: { login_validator: check_login } + endpoints: [] + """, + "validator-missing": """ + auth: { login_validator: diff_validators.nothing_here } + endpoints: [] + """, + "validator-async": """ + auth: { login_validator: diff_validators.async_login } + endpoints: [] + """, + "validator-wrong-arity": """ + auth: { login_validator: diff_validators.one_argument_login } + endpoints: [] + """, + "validator-not-callable": """ + auth: { login_validator: diff_validators.NOT_CALLABLE } + endpoints: [] + """, + "env-var-not-set": """ + database: { url: "${DFY_VARIABLE_THAT_IS_NOT_SET}" } + endpoints: [] + """, + "bad-mode": """ + mode: sideways + endpoints: [] + """, + "bad-pagination-style": """ + endpoints: + - route: /dfy-bad4 + fields: { a: { type: str } } + meta: { pagination: { style: spiral } } + """, +} + + +def yaml_config(rec: Recorder, tmp_dir: str) -> None: + config = Path(tmp_dir) / "valid.yaml" + config.write_text(VALID_YAML, encoding="utf-8") + app = LightApi.from_config(str(config), engine=memory_engine()) + client = client_for(app) + user, admin = bearer(USER), bearer(ADMIN) + steps: list[Step] = [ + ("announcements-anonymous", "GET", "/dfy-announcements", {}), + ( + "announcements-create", + "POST", + "/dfy-announcements", + {"json": {"title": "b"}}, + ), + ( + "announcements-create-2", + "POST", + "/dfy-announcements", + {"json": {"title": "a", "pinned": True}}, + ), + ( + "announcements-create-3", + "POST", + "/dfy-announcements", + {"json": {"title": "c", "score": 3}}, + ), + ( + "announcements-too-long", + "POST", + "/dfy-announcements", + {"json": {"title": "x" * 21}}, + ), + ("announcements-page-1", "GET", "/dfy-announcements?ordering=title", {}), + ("announcements-page-2", "GET", "/dfy-announcements?ordering=title&page=2", {}), + ("announcements-filter", "GET", "/dfy-announcements?pinned=true", {}), + ("announcements-put-not-allowed", "PUT", "/dfy-announcements/1", {"json": {}}), + ("tickets-get-anonymous", "GET", "/dfy-tickets", {}), + ("tickets-post-anonymous", "POST", "/dfy-tickets", {"json": {"subject": "s"}}), + ( + "tickets-post-user", + "POST", + "/dfy-tickets", + {"headers": user, "json": {"subject": "s", "cost": "1.50"}}, + ), + ("tickets-delete-user", "DELETE", "/dfy-tickets/1", {"headers": user}), + ("tickets-delete-admin", "DELETE", "/dfy-tickets/1", {"headers": admin}), + ("tickets-patch-not-allowed", "PATCH", "/dfy-tickets/1", {"json": {}}), + ("things-anonymous", "GET", "/api/v1/dfy-things", {}), + ( + "things-create", + "POST", + "/api/v1/dfy-things", + {"headers": user, "json": {"name": "n"}}, + ), + ("things-list", "GET", "/api/v1/dfy-things", {"headers": user}), + ( + "login", + "POST", + "/auth/login", + {"json": {"username": "alice", "password": "secret"}}, + ), + ] + play(rec, "yaml", client, steps) + rec.note( + "yaml/endpoint-class-names", + sorted(c.__name__ for c in app._endpoint_map.values()), + ) + + for label, document in INVALID_YAML.items(): + broken = Path(tmp_dir) / f"{label}.yaml" + broken.write_text(textwrap.dedent(document), encoding="utf-8") + try: + LightApi.from_config(str(broken), engine=memory_engine()) + rec.note(f"yaml-invalid/{label}", "accepted") + except Exception as error: + rec.raised(f"yaml-invalid/{label}", error) + + +async def play_async(rec: Recorder, group: str, client: Any, steps: list[Step]) -> None: + for label, method, url, kwargs in steps: + rec.http(f"{group}/{label}", await client.request(method, url, **kwargs)) + + +def async_client(app: Any) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app.build_app(), raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://testserver") + + +async def async_engine_scenarios(rec: Recorder, fake_redis: Any) -> None: + background_log: list[str] = [] + + class DfaArticle(RestEndpoint): + title: str = Field(min_length=1) + category: str = Field(min_length=1) + + class Meta: + filtering = Filtering( + backends=[FieldFilter, OrderingFilter], + fields=["category"], + ordering=["title"], + ) + pagination = Pagination(style="page_number", page_size=2) + + class DfaEvent(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + pagination = Pagination(style="cursor", page_size=2) + + class DfaSecret(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + authentication = Authentication(JWTAuthentication, IsAuthenticated) + + class DfaOwned(RestEndpoint): + name: str = Field(min_length=1) + owner: str = Field(min_length=1) + + async def queryset(self, request: Any) -> Any: + model = type(self)._model_class + return select(model).where( + model.owner == request.query_params.get("owner", "ana") + ) + + class DfaSyncQueryset(RestEndpoint): + name: str = Field(min_length=1) + + def queryset(self, request: Any) -> Any: + model = type(self)._model_class + return select(model).order_by(model.name.desc()) + + class DfaHooked(RestEndpoint): + name: str = Field(min_length=1) + + async def post(self, request: Any) -> Any: + import json + + response = await self._create_async(json.loads(await request.body())) + if response.status_code == 201: + self.background(background_log.append, "created") + return response + + async def get(self, request: Any) -> Any: + listed = await self._list_async(request) + listed.headers["x-trace"] = "async-override" + return listed + + async def put(self, request: Any) -> Any: + import json + + pk = request.path_params["id"] + return await self._update_async(json.loads(await request.body()), pk) + + async def delete(self, request: Any) -> Any: + return await self._destroy_async(request, request.path_params["id"]) + + engine = create_async_engine("sqlite+aiosqlite://") + app = LightApi(engine=engine, login_validator=check_login) + app.register( + { + "/dfabooks": book_endpoint("DfaBook"), + "/dfaarticles": DfaArticle, + "/dfaevents": DfaEvent, + "/dfasecrets": DfaSecret, + "/dfaowned": DfaOwned, + "/dfasyncqs": DfaSyncQueryset, + "/dfahooked": DfaHooked, + } + ) + async with async_client(app) as client: + await play_async(rec, "async-crud", client, crud_steps("/dfabooks")) + + for title, category in (("b", "x"), ("a", "x"), ("c", "y")): + await client.post( + "/dfaarticles", json={"title": title, "category": category} + ) + for query in ("", "?category=x", "?ordering=-title", "?page=2", "?page=9"): + rec.http( + f"async-filtering{query or '?none'}", + await client.get(f"/dfaarticles{query}"), + ) + + for n in range(5): + await client.post("/dfaevents", json={"name": f"e{n}"}) + url, hops = "/dfaevents", 0 + while url and hops < 5: + page = rec.http(f"async-cursor/page-{hops}", await client.get(url)) + cursor = page.json().get("next") if page.status_code == 200 else None + url, hops = (f"/dfaevents?cursor={cursor}" if cursor else None), hops + 1 + + user = bearer(USER) + secrets: list[Step] = [ + ("anonymous", "GET", "/dfasecrets", {}), + ("user", "GET", "/dfasecrets", {"headers": user}), + ( + "user-create", + "POST", + "/dfasecrets", + {"headers": user, "json": {"text": "t"}}, + ), + ( + "login", + "POST", + "/auth/login", + {"json": {"username": "alice", "password": "secret"}}, + ), + ] + await play_async(rec, "async-auth", client, secrets) + + for name, owner in (("one", "ana"), ("two", "bia"), ("three", "ana")): + await client.post("/dfaowned", json={"name": name, "owner": owner}) + rec.http("async-queryset/default-owner", await client.get("/dfaowned")) + rec.http("async-queryset/other-owner", await client.get("/dfaowned?owner=bia")) + for name in ("m", "z", "a"): + await client.post("/dfasyncqs", json={"name": name}) + rec.http( + "async-queryset/sync-queryset-on-async-app", await client.get("/dfasyncqs") + ) + + hooked: list[Step] = [ + ("post-override", "POST", "/dfahooked", {"json": {"name": "h"}}), + ("post-override-invalid", "POST", "/dfahooked", {"json": {}}), + ("get-override", "GET", "/dfahooked", {}), + ( + "put-override", + "PUT", + "/dfahooked/1", + {"json": {"name": "h2", "version": 1}}, + ), + ( + "put-override-conflict", + "PUT", + "/dfahooked/1", + {"json": {"name": "h3", "version": 1}}, + ), + ("delete-override", "DELETE", "/dfahooked/1", {}), + ("delete-override-again", "DELETE", "/dfahooked/1", {}), + ] + await play_async(rec, "async-overrides", client, hooked) + rec.note("async-overrides/background-tasks-ran", list(background_log)) + + await engine.dispose() + await _async_engine_without_mode(rec) + await _async_cache(rec, fake_redis) + + +async def _async_engine_without_mode(rec: Recorder) -> None: + """An AsyncEngine alone: the README calls this a one-line swap.""" + + class DfaPlain(RestEndpoint): + name: str = Field(min_length=1) + + engine = create_async_engine("sqlite+aiosqlite://") + app = LightApi(engine=engine) + app.register({"/dfaplain": DfaPlain}) + async with async_client(app) as client: + rec.http( + "async-no-mode/create", await client.post("/dfaplain", json={"name": "x"}) + ) + rec.http("async-no-mode/list", await client.get("/dfaplain")) + await engine.dispose() + + +async def _async_cache(rec: Recorder, fake_redis: Any) -> None: + class DfaCached(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + cache = Cache(ttl=60) + + fake_redis.store.clear() + engine = create_async_engine("sqlite+aiosqlite://") + # No endpoint method is async here, so the mode has to be stated. + app = LightApi(engine=engine, mode="async") + app.register({"/dfacached": DfaCached}) + async with async_client(app) as client: + await client.post("/dfacached", json={"name": "original"}) + rec.http("cache-async/first-get", await client.get("/dfacached")) + rec.note("cache-async/keys-after-first-get", sorted(fake_redis.store)) + async with engine.begin() as connection: + await connection.execute( + text("UPDATE dfacacheds SET name = 'changed in the database'") + ) + rec.http("cache-async/second-get", await client.get("/dfacached")) + rec.http( + "cache-async/post", await client.post("/dfacached", json={"name": "new"}) + ) + rec.note("cache-async/keys-after-write", sorted(fake_redis.store)) + rec.http("cache-async/get-after-write", await client.get("/dfacached")) + await engine.dispose() + + +def declared_changes(rec: Recorder) -> None: + """Steps that the CHANGELOG says behave differently now. See compare.py.""" + + class DfSyncOverride(RestEndpoint): + name: str = Field(min_length=1) + + def get(self, request: Any) -> Any: + return {"served_by": "sync override"} + + def post(self, request: Any) -> Any: + return self.create({"name": "from the override"}) + + app = LightApi(engine=memory_engine()) + app.register({"/dfsyncoverride": DfSyncOverride}) + client = client_for(app) + rec.http("changes/sync-override-get", client.get("/dfsyncoverride")) + rec.http("changes/sync-override-get-detail", client.get("/dfsyncoverride/1")) + rec.http( + "changes/sync-override-post", + client.post("/dfsyncoverride", json={"name": "sent"}), + ) + + class DirectoryBackend(JWTAuthentication): + def validate_credentials(self, username: str, password: str) -> Any: + return check_login(username, password) + + class DfDirectory(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + authentication = Authentication(DirectoryBackend, IsAuthenticated) + + subclassed = LightApi(engine=memory_engine()) + subclassed.register({"/dfdirectory": DfDirectory}) + client = client_for(subclassed) + good = {"username": "alice", "password": "secret"} + rec.http( + "changes/login-with-subclass-backend", client.post("/auth/login", json=good) + ) + rec.http( + "changes/login-with-subclass-wrong-password", + client.post("/auth/login", json={**good, "password": "x"}), + ) + rec.http("changes/subclass-backend-still-guards", client.get("/dfdirectory")) + rec.http( + "changes/subclass-backend-accepts-token", + client.get("/dfdirectory", headers=bearer(USER)), + ) diff --git a/tools/differential/scenarios_sync.py b/tools/differential/scenarios_sync.py new file mode 100644 index 0000000..32355a2 --- /dev/null +++ b/tools/differential/scenarios_sync.py @@ -0,0 +1,756 @@ +"""Sync-engine scenarios of the differential run, one function per feature area.""" + +from __future__ import annotations + +import datetime +import time +import uuid +from decimal import Decimal +from typing import Any, Optional + +import jwt +from diff_validators import check_login, exploding_login +from harness import JWT_SECRET, Recorder, bearer, client_for, memory_engine +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + Integer, + MetaData, + Numeric, + String, + Table, + Text, + create_engine, + select, + text, +) + +from lightapi import ( + AllowAny, + Authentication, + BasicAuthentication, + Cache, + Field, + FieldFilter, + Filtering, + HttpMethod, + IsAdminUser, + IsAuthenticated, + JWTAuthentication, + LightApi, + Middleware, + OrderingFilter, + Pagination, + RestEndpoint, + SearchFilter, + Serializer, +) +from lightapi.core import CORSMiddleware + +Step = tuple[str, str, str, dict[str, Any]] +FIXED_UID = "6f1c1f0a-5f0e-4c3b-9d2a-0c7e8f9a1b2c" +BASIC_ALICE = {"Authorization": "Basic YWxpY2U6c2VjcmV0"} # alice:secret +BASIC_WRONG = {"Authorization": "Basic YWxpY2U6d3Jvbmc="} # alice:wrong +BASIC_BROKEN = {"Authorization": "Basic !!!not-base64!!!"} +USER = {"sub": "alice", "is_admin": False} +ADMIN = {"sub": "root", "is_admin": True} + + +def play(rec: Recorder, group: str, client: Any, steps: list[Step]) -> None: + for label, method, url, kwargs in steps: + rec.http(f"{group}/{label}", client.request(method, url, **kwargs)) + + +def book_endpoint(name: str) -> type: + """A fresh endpoint class: a class can only be mapped to one table.""" + namespace = { + "__module__": __name__, + "__annotations__": { + "title": str, + "pages": int, + "price": Decimal, + "rating": Optional[float], + "in_print": bool, + "published": Optional[datetime.datetime], + "uid": Optional[uuid.UUID], + }, + "title": Field(min_length=1, max_length=50), + "pages": Field(ge=1), + "price": Field(decimal_places=2), + "rating": Field(default=None), + "in_print": Field(default=True), + "published": Field(default=None), + "uid": Field(default=None), + } + return type(name, (RestEndpoint,), namespace) + + +def crud_steps(route: str) -> list[Step]: + full = { + "title": "Dune", + "pages": 412, + "price": "9.99", + "rating": 4.5, + "in_print": False, + "published": "1965-08-01T00:00:00", + "uid": FIXED_UID, + } + one = f"{route}/1" + return [ + ("list-empty", "GET", route, {}), + ("create-full", "POST", route, {"json": full}), + ( + "create-minimal", + "POST", + route, + {"json": {"title": "B", "pages": 1, "price": 1}}, + ), + ("create-missing-field", "POST", route, {"json": {"title": "x"}}), + ("create-wrong-type", "POST", route, {"json": {**full, "pages": "many"}}), + ("create-too-long", "POST", route, {"json": {**full, "title": "x" * 51}}), + ("create-below-minimum", "POST", route, {"json": {**full, "pages": 0}}), + ("create-extra-field", "POST", route, {"json": {**full, "colour": "red"}}), + ( + "create-auto-field", + "POST", + route, + {"json": {**full, "id": 99, "version": 7}}, + ), + ("create-not-json", "POST", route, {"content": b"{not json"}), + ("create-empty-body", "POST", route, {}), + ("list-after-create", "GET", route, {}), + ("retrieve", "GET", one, {}), + ("retrieve-missing", "GET", f"{route}/999", {}), + ("retrieve-not-int", "GET", f"{route}/abc", {}), + ("put-without-version", "PUT", one, {"json": full}), + ("put-stale-version", "PUT", one, {"json": {**full, "version": 9}}), + ("put-ok", "PUT", one, {"json": {**full, "title": "Dune II", "version": 1}}), + ("put-missing-field", "PUT", one, {"json": {"title": "x", "version": 2}}), + ("put-missing-row", "PUT", f"{route}/999", {"json": {**full, "version": 1}}), + ("patch-one-field", "PATCH", one, {"json": {"pages": 500, "version": 2}}), + ("patch-null-nullable", "PATCH", one, {"json": {"rating": None, "version": 3}}), + ("patch-null-required", "PATCH", one, {"json": {"title": None, "version": 4}}), + ("patch-wrong-type", "PATCH", one, {"json": {"pages": "x", "version": 5}}), + ("patch-stale-version", "PATCH", one, {"json": {"pages": 1, "version": 1}}), + ("patch-missing-row", "PATCH", f"{route}/999", {"json": {"version": 1}}), + ("retrieve-after-updates", "GET", one, {}), + ("head-collection", "HEAD", route, {}), + ("options-collection", "OPTIONS", route, {}), + ("post-on-detail", "POST", one, {"json": full}), + ("delete-on-collection", "DELETE", route, {}), + ("trailing-slash", "GET", f"{route}/", {"follow_redirects": False}), + ("delete", "DELETE", one, {}), + ("delete-again", "DELETE", one, {}), + ("list-after-delete", "GET", route, {}), + ] + + +def crud(rec: Recorder, tmp_dir: str) -> None: + app = LightApi(engine=memory_engine()) + app.register({"/dfbooks": book_endpoint("DfBook")}) + client = client_for(app) + play(rec, "crud-memory", client, crud_steps("/dfbooks")) + play( + rec, + "crud-memory", + client, + [("healthz", "GET", "/healthz", {}), ("unknown-route", "GET", "/nope", {})], + ) + + # A file database gets a real connection pool, which is the engine kind + # whose sync CRUD the refactor moved to a worker thread. + pooled = LightApi(engine=create_engine(f"sqlite:///{tmp_dir}/pooled.db")) + pooled.register({"/dfpooledbooks": book_endpoint("DfPooledBook")}) + play(rec, "crud-pooled", client_for(pooled), crud_steps("/dfpooledbooks")) + + +def allowed_methods(rec: Recorder) -> None: + class DfReadOnly(RestEndpoint, HttpMethod.GET): + name: str = Field(min_length=1) + + class DfNoDelete(RestEndpoint, HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT): + name: str = Field(min_length=1) + + app = LightApi(engine=memory_engine()) + app.register({"/dfreadonly": DfReadOnly, "/dfnodelete": DfNoDelete}) + play( + rec, + "methods", + client_for(app), + [ + ("read-only-get", "GET", "/dfreadonly", {}), + ("read-only-post", "POST", "/dfreadonly", {"json": {"name": "x"}}), + ("read-only-head", "HEAD", "/dfreadonly", {}), + ("read-only-detail-put", "PUT", "/dfreadonly/1", {"json": {}}), + ("no-delete-post", "POST", "/dfnodelete", {"json": {"name": "x"}}), + ("no-delete-delete", "DELETE", "/dfnodelete/1", {}), + ("no-delete-patch", "PATCH", "/dfnodelete/1", {"json": {"version": 1}}), + ( + "no-delete-put", + "PUT", + "/dfnodelete/1", + {"json": {"name": "y", "version": 1}}, + ), + ], + ) + + +def serializers(rec: Recorder) -> None: + class PublicView(Serializer): + read = ["id", "name", "version"] + write = ["id"] + + def endpoint(name: str, serializer: Any) -> type: + meta = type("Meta", (), {"serializer": serializer}) + namespace = { + "__module__": __name__, + "__annotations__": {"name": str, "secret": Optional[str]}, + "name": Field(min_length=1), + "secret": Field(default=None), + "Meta": meta, + } + return type(name, (RestEndpoint,), namespace) + + routes = { + "/dfser-all": endpoint("DfSerAll", Serializer()), + "/dfser-fields": endpoint("DfSerFields", Serializer(fields=["id", "name"])), + "/dfser-split": endpoint( + "DfSerSplit", + Serializer(read=["id", "name", "secret"], write=["id", "version"]), + ), + "/dfser-subclass": endpoint("DfSerSubclass", PublicView), + } + app = LightApi(engine=memory_engine()) + app.register(routes) + client = client_for(app) + body = {"name": "visible", "secret": "hidden"} + for route in routes: + play( + rec, + f"serializer{route}", + client, + [ + ("create", "POST", route, {"json": body}), + ("list", "GET", route, {}), + ("retrieve", "GET", f"{route}/1", {}), + ("put", "PUT", f"{route}/1", {"json": {**body, "version": 1}}), + ], + ) + + +def filtering_and_pagination(rec: Recorder) -> None: + class DfArticle(RestEndpoint): + title: str = Field(min_length=1) + body: str = Field(min_length=1) + category: str = Field(min_length=1) + published: bool = Field(default=False) + views: int = Field(default=0) + + class Meta: + filtering = Filtering( + backends=[FieldFilter, SearchFilter, OrderingFilter], + fields=["category", "published", "views"], + search=["title", "body"], + ordering=["title", "views"], + ) + pagination = Pagination(style="page_number", page_size=3) + + class DfEvent(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + pagination = Pagination(style="cursor", page_size=3) + + class DfUnordered(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + filtering = Filtering(backends=[OrderingFilter]) + + app = LightApi(engine=memory_engine()) + app.register( + {"/dfarticles": DfArticle, "/dfevents": DfEvent, "/dfunordered": DfUnordered} + ) + client = client_for(app) + rows = [ + ("python tips", "use 100% of it", "tech", True, 30), + ("a_b testing", "split traffic", "tech", False, 10), + ("axb testing", "not the same", "tech", True, 20), + ("gardening", "plant python beans", "home", True, 5), + ("50% off", "sale of the year", "shop", False, 50), + ("cooking", "low heat", "home", False, 15), + ("travel", "pack light", "life", True, 25), + ("zebra facts", "stripes", "life", False, 1), + ] + for title, body, category, published, views in rows: + client.post( + "/dfarticles", + json={ + "title": title, + "body": body, + "category": category, + "published": published, + "views": views, + }, + ) + queries = [ + "", + "?category=tech", + "?published=true", + "?published=0", + "?views=10", + "?views=ten", + "?title=gardening", + "?category=tech&published=true", + "?search=python", + "?search=100%25", + "?search=a_b", + "?search=", + "?ordering=-views", + "?ordering=title,-views", + "?ordering=body", + "?ordering=-views&page=2", + "?page=2", + "?page=3", + "?page=0", + "?page=99", + "?page=abc", + "?page=2&category=tech", + ] + for query in queries: + rec.http( + f"filtering/articles{query or '?none'}", client.get(f"/dfarticles{query}") + ) + + for n in range(7): + client.post("/dfevents", json={"name": f"event-{n}"}) + url, hops = "/dfevents", 0 + while url and hops < 6: + page = rec.http(f"cursor/page-{hops}", client.get(url)) + cursor = page.json().get("next") if page.status_code == 200 else None + url, hops = (f"/dfevents?cursor={cursor}" if cursor else None), hops + 1 + rec.http("cursor/garbage", client.get("/dfevents?cursor=%%%")) + + for name in ("b", "a", "c"): + client.post("/dfunordered", json={"name": name}) + rec.http("ordering/no-whitelist", client.get("/dfunordered?ordering=-name")) + + +def auth_endpoints() -> dict[str, type]: + def endpoint(name: str, authentication: Authentication) -> type: + meta = type("Meta", (), {"authentication": authentication}) + namespace = { + "__module__": __name__, + "__annotations__": {"text": str}, + "text": Field(min_length=1), + "Meta": meta, + } + return type(name, (RestEndpoint,), namespace) + + jwt_, basic = JWTAuthentication, BasicAuthentication + per_method = {"GET": AllowAny, "POST": IsAuthenticated, "DELETE": IsAdminUser} + return { + "/dfjwt": endpoint("DfJwt", Authentication(jwt_, IsAuthenticated)), + "/dfadmin": endpoint("DfAdmin", Authentication(jwt_, IsAdminUser)), + "/dfbasic": endpoint("DfBasic", Authentication(basic, IsAuthenticated)), + "/dfpublic": endpoint("DfPublic", Authentication(jwt_, AllowAny)), + "/dfpermethod": endpoint("DfPerMethod", Authentication(jwt_, per_method)), + "/dfbackendonly": endpoint("DfBackendOnly", Authentication(jwt_)), + "/dfpermissiononly": endpoint( + "DfPermissionOnly", Authentication(permission=IsAuthenticated) + ), + } + + +def authentication(rec: Recorder) -> None: + app = LightApi(engine=memory_engine(), login_validator=check_login) + app.register(auth_endpoints()) + client = client_for(app) + user, admin = bearer(USER), bearer(ADMIN) + expired = bearer(USER, expiration=-10) + forged = { + "Authorization": "Bearer " + + jwt.encode({"sub": "mallory"}, "another-secret", algorithm="HS256") + } + note = {"json": {"text": "hello"}} + steps: list[Step] = [ + ("jwt-anonymous", "GET", "/dfjwt", {}), + ("jwt-garbage", "GET", "/dfjwt", {"headers": {"Authorization": "Bearer x"}}), + ("jwt-wrong-scheme", "GET", "/dfjwt", {"headers": BASIC_ALICE}), + ("jwt-expired", "GET", "/dfjwt", {"headers": expired}), + ("jwt-forged", "GET", "/dfjwt", {"headers": forged}), + ("jwt-user", "GET", "/dfjwt", {"headers": user}), + ("jwt-user-create", "POST", "/dfjwt", {"headers": user, **note}), + ("admin-as-user", "GET", "/dfadmin", {"headers": user}), + ("admin-as-admin", "GET", "/dfadmin", {"headers": admin}), + ("admin-anonymous", "GET", "/dfadmin", {}), + ("basic-anonymous", "GET", "/dfbasic", {}), + ("basic-valid", "GET", "/dfbasic", {"headers": BASIC_ALICE}), + ("basic-wrong-password", "GET", "/dfbasic", {"headers": BASIC_WRONG}), + ("basic-malformed", "GET", "/dfbasic", {"headers": BASIC_BROKEN}), + ("basic-with-bearer", "GET", "/dfbasic", {"headers": user}), + ("public-anonymous", "GET", "/dfpublic", {}), + ("public-anonymous-create", "POST", "/dfpublic", note), + ("per-method-get-anonymous", "GET", "/dfpermethod", {}), + ("per-method-post-anonymous", "POST", "/dfpermethod", note), + ("per-method-post-user", "POST", "/dfpermethod", {"headers": user, **note}), + ("per-method-delete-user", "DELETE", "/dfpermethod/1", {"headers": user}), + ("per-method-delete-admin", "DELETE", "/dfpermethod/1", {"headers": admin}), + ("per-method-put-anonymous", "PUT", "/dfpermethod/1", {"json": {}}), + ("backend-only-anonymous", "GET", "/dfbackendonly", {}), + ("backend-only-user", "GET", "/dfbackendonly", {"headers": user}), + ("permission-only-anonymous", "GET", "/dfpermissiononly", {}), + ("permission-only-user", "GET", "/dfpermissiononly", {"headers": user}), + ] + play(rec, "auth", client, steps) + + good = {"username": "alice", "password": "secret"} + login: list[Step] = [ + ("login-json", "POST", "/auth/login", {"json": good}), + ("token-json", "POST", "/auth/token", {"json": good}), + ( + "login-wrong-password", + "POST", + "/auth/login", + {"json": {**good, "password": "x"}}, + ), + ( + "login-unknown-user", + "POST", + "/auth/login", + {"json": {**good, "username": "x"}}, + ), + ("login-missing-password", "POST", "/auth/login", {"json": {"username": "a"}}), + ( + "login-empty-username", + "POST", + "/auth/login", + {"json": {**good, "username": ""}}, + ), + ("login-empty-body", "POST", "/auth/login", {}), + ("login-not-json", "POST", "/auth/login", {"content": b"user=alice"}), + ("login-basic-header", "POST", "/auth/login", {"headers": BASIC_ALICE}), + ("login-basic-wrong", "POST", "/auth/login", {"headers": BASIC_WRONG}), + ("login-basic-malformed", "POST", "/auth/login", {"headers": BASIC_BROKEN}), + ("login-get", "GET", "/auth/login", {}), + ] + play(rec, "login", client, login) + issued = client.post("/auth/login", json={"username": "root", "password": "toor"}) + claims = jwt.decode(issued.json()["token"], JWT_SECRET, algorithms=["HS256"]) + rec.note("login/token-claims", sorted(claims)) + rec.note("login/token-minutes", round((claims["exp"] - time.time()) / 60)) + reuse = {"Authorization": f"Bearer {issued.json()['token']}"} + rec.http("login/issued-token-works", client.get("/dfadmin", headers=reuse)) + + +def login_variants(rec: Recorder) -> None: + def protected(name: str, authentication: Authentication) -> type: + meta = type("Meta", (), {"authentication": authentication}) + namespace = { + "__module__": __name__, + "__annotations__": {"text": str}, + "Meta": meta, + } + return type(name, (RestEndpoint,), namespace) + + good = {"username": "alice", "password": "secret"} + + claims_app = LightApi(engine=memory_engine(), login_validator=check_login) + claims_auth = Authentication( + JWTAuthentication, + IsAuthenticated, + jwt_expiration=300, + jwt_extra_claims=["role", "absent"], + ) + claims_app.register({"/dfclaims": protected("DfClaims", claims_auth)}) + issued = client_for(claims_app).post("/auth/login", json=good) + rec.http("login-claims/login", issued) + claims = jwt.decode(issued.json()["token"], JWT_SECRET, algorithms=["HS256"]) + rec.note("login-claims/token-claims", sorted(claims)) + rec.note("login-claims/token-minutes", round((claims["exp"] - time.time()) / 60)) + + rooted = LightApi( + engine=memory_engine(), login_validator=check_login, auth_path="/" + ) + rooted.register( + {"/dfrooted": protected("DfRooted", Authentication(JWTAuthentication))} + ) + client = client_for(rooted) + rec.http("login-root-path/login", client.post("/login", json=good)) + rec.http("login-root-path/old-path", client.post("/auth/login", json=good)) + + basic_app = LightApi(engine=memory_engine(), login_validator=check_login) + basic_auth = Authentication(BasicAuthentication, IsAuthenticated) + basic_app.register({"/dfbasiconly": protected("DfBasicOnly", basic_auth)}) + rec.http( + "login-basic-app/login", client_for(basic_app).post("/auth/login", json=good) + ) + + no_auth = LightApi(engine=memory_engine(), login_validator=check_login) + no_auth.register( + {"/dfopen": protected("DfOpen", Authentication(permission=AllowAny))} + ) + rec.http( + "login-no-backend/login", client_for(no_auth).post("/auth/login", json=good) + ) + + broken = LightApi(engine=memory_engine(), login_validator=exploding_login) + broken.register( + {"/dfbroken": protected("DfBroken", Authentication(JWTAuthentication))} + ) + rec.http( + "login-validator-raises/login", + client_for(broken).post("/auth/login", json=good), + ) + + limited = LightApi( + engine=memory_engine(), + login_validator=check_login, + rate_limiter={"requests_per_minute": 2}, + ) + limited.register( + {"/dflimited": protected("DfLimited", Authentication(JWTAuthentication))} + ) + client = client_for(limited) + for attempt in range(4): + rec.http( + f"login-rate-limit/attempt-{attempt}", client.post("/auth/login", json=good) + ) + + +def middleware(rec: Recorder) -> None: + calls: list[str] = [] + + class TraceOuter(Middleware): + def process(self, request: Any, response: Any) -> Any: + calls.append("outer-pre" if response is None else "outer-post") + return response + + class TraceInnerAsync(Middleware): + async def process(self, request: Any, response: Any) -> Any: + calls.append("inner-pre" if response is None else "inner-post") + if response is not None: + response.headers["x-trace"] = "inner" + return response + + class Blocker(Middleware): + def process(self, request: Any, response: Any) -> Any: + if response is None and request.headers.get("x-block"): + from starlette.responses import JSONResponse + + return JSONResponse({"detail": "blocked"}, status_code=403) + return response + + class DfTraced(RestEndpoint): + name: str = Field(min_length=1) + + app = LightApi( + engine=memory_engine(), middlewares=[TraceOuter, TraceInnerAsync, Blocker] + ) + app.register({"/dftraced": DfTraced}) + client = client_for(app) + rec.http("middleware/get", client.get("/dftraced")) + rec.note("middleware/get-calls", list(calls)) + calls.clear() + rec.http("middleware/blocked", client.post("/dftraced", headers={"x-block": "1"})) + rec.note("middleware/blocked-calls", list(calls)) + rec.http("middleware/healthz-is-not-wrapped", client.get("/healthz")) + + class DfCors(RestEndpoint): + name: str = Field(min_length=1) + + origin = {"Origin": "https://app.example.com"} + preflight = {**origin, "Access-Control-Request-Method": "POST"} + starlette_cors = LightApi( + engine=memory_engine(), cors_origins=["https://app.example.com"] + ) + starlette_cors.register({"/dfcors": DfCors}) + client = client_for(starlette_cors) + rec.http("cors-origins/preflight", client.options("/dfcors", headers=preflight)) + rec.http("cors-origins/get", client.get("/dfcors", headers=origin)) + rec.http( + "cors-origins/other-origin", + client.get("/dfcors", headers={"Origin": "https://x.io"}), + ) + + class DfOwnCors(RestEndpoint): + name: str = Field(min_length=1) + + own_cors = LightApi(engine=memory_engine(), middlewares=[CORSMiddleware]) + own_cors.register({"/dfowncors": DfOwnCors}) + client = client_for(own_cors) + rec.http("cors-middleware/get", client.get("/dfowncors", headers=origin)) + rec.http("cors-middleware/post", client.post("/dfowncors", json={"name": "x"})) + rec.http("cors-middleware/not-found", client.get("/dfowncors/9")) + rec.http("cors-middleware/delete", client.delete("/dfowncors/1")) + + +def reflection(rec: Recorder) -> None: + engine = memory_engine() + legacy = MetaData() + for name in ("df_legacy", "df_partial"): + Table( + name, + legacy, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("label", String(40), nullable=False), + Column("notes", Text, nullable=True), + Column("amount", Numeric(10, 2), nullable=True), + Column("ratio", Float, nullable=True), + Column("active", Boolean, nullable=True), + Column("created_at", DateTime), + Column("updated_at", DateTime), + Column("version", Integer, nullable=False, default=1), + ) + legacy.create_all(engine) + + class DfLegacy(RestEndpoint): + class Meta: + reflect = True + table = "df_legacy" + + class DfPartial(RestEndpoint): + notes: Optional[str] = Field(default=None) + + class Meta: + reflect = "partial" + table = "df_partial" + + class DfGhost(RestEndpoint): + class Meta: + reflect = True + table = "df_does_not_exist" + + app = LightApi(engine=engine) + app.register({"/dflegacy": DfLegacy, "/dfpartial": DfPartial}) + client = client_for(app) + row = { + "label": "first", + "notes": "n", + "amount": "12.50", + "ratio": 0.5, + "active": True, + } + for route in ("/dflegacy", "/dfpartial"): + play( + rec, + f"reflection{route}", + client, + [ + ("create", "POST", route, {"json": row}), + ("create-missing-required", "POST", route, {"json": {"notes": "x"}}), + ( + "create-wrong-type", + "POST", + route, + {"json": {**row, "amount": "abc"}}, + ), + ("list", "GET", route, {}), + ( + "patch", + "PATCH", + f"{route}/1", + {"json": {"notes": None, "version": 1}}, + ), + ( + "put", + "PUT", + f"{route}/1", + {"json": {**row, "label": "second", "version": 2}}, + ), + ("delete", "DELETE", f"{route}/1", {}), + ], + ) + try: + LightApi(engine=engine).register({"/dfghost": DfGhost}) + except Exception as error: + rec.raised("reflection/missing-table", error) + + +def custom_queryset(rec: Recorder) -> None: + class DfTask(RestEndpoint): + name: str = Field(min_length=1) + done: bool = Field(default=False) + + def queryset(self, request: Any) -> Any: + model = type(self)._model_class + return ( + select(model).where(model.done.is_(False)).order_by(model.name.desc()) + ) + + app = LightApi(engine=memory_engine()) + app.register({"/dftasks": DfTask}) + client = client_for(app) + for name, done in (("a", False), ("b", True), ("c", False)): + client.post("/dftasks", json={"name": name, "done": done}) + rec.http("queryset/list-filters-and-orders", client.get("/dftasks")) + rec.http("queryset/retrieve-ignores-queryset", client.get("/dftasks/2")) + + +def response_cache(rec: Recorder, fake_redis: Any) -> None: + class DfCached(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + cache = Cache(ttl=60) + + engine = memory_engine() + app = LightApi(engine=engine) + app.register({"/dfcached": DfCached}) + client = client_for(app) + client.post("/dfcached", json={"name": "original"}) + + def change_row_behind_the_cache() -> None: + with engine.begin() as connection: + connection.execute( + text("UPDATE dfcacheds SET name = 'changed in the database'") + ) + + rec.http("cache-sync/first-get-misses", client.get("/dfcached")) + rec.note("cache-sync/keys-after-first-get", sorted(fake_redis.store)) + change_row_behind_the_cache() + rec.http("cache-sync/second-get-is-served-from-cache", client.get("/dfcached")) + rec.http("cache-sync/other-query-string-misses", client.get("/dfcached?x=1")) + rec.http("cache-sync/detail-get", client.get("/dfcached/1")) + rec.note("cache-sync/keys-before-write", sorted(fake_redis.store)) + rec.http( + "cache-sync/post-invalidates", client.post("/dfcached", json={"name": "new"}) + ) + rec.note("cache-sync/keys-after-write", sorted(fake_redis.store)) + rec.http("cache-sync/get-after-write", client.get("/dfcached")) + rec.http("cache-sync/not-found-is-not-cached", client.get("/dfcached/99")) + rec.note("cache-sync/keys-at-end", sorted(fake_redis.store)) + + +def schema_ddl(rec: Recorder) -> None: + """Every table the run created, as each dialect would create it.""" + from sqlalchemy.dialects import postgresql, sqlite + from sqlalchemy.schema import CreateIndex, CreateTable + + probe = LightApi(engine=memory_engine()) + metadata = probe._session_manager.metadata + for dialect_name, dialect in ( + ("sqlite", sqlite.dialect()), + ("postgresql", postgresql.dialect()), + ): + for table in sorted(metadata.tables.values(), key=lambda t: t.name): + ddl = str(CreateTable(table).compile(dialect=dialect)).strip() + rec.note(f"ddl/{dialect_name}/{table.name}", ddl) + for index in sorted(table.indexes, key=lambda i: i.name or ""): + created = str(CreateIndex(index).compile(dialect=dialect)).strip() + rec.note(f"ddl/{dialect_name}/{table.name}/index/{index.name}", created) + + +def run(rec: Recorder, tmp_dir: str, fake_redis: Any) -> None: + crud(rec, tmp_dir) + allowed_methods(rec) + serializers(rec) + filtering_and_pagination(rec) + authentication(rec) + login_variants(rec) + middleware(rec) + reflection(rec) + custom_queryset(rec) + response_cache(rec, fake_redis) diff --git a/tools/differential/snapshot_public_api.py b/tools/differential/snapshot_public_api.py new file mode 100644 index 0000000..6f46326 --- /dev/null +++ b/tools/differential/snapshot_public_api.py @@ -0,0 +1,121 @@ +"""Record the public API that the README, docs and examples tell users to import. + +Run it against a released version to produce the snapshot that +tests/test_public_api.py checks the working tree against: + + uv run --no-project --isolated --python 3.12 \\ + --with "lightapi[async]==0.1.29" \\ + python tools/differential/snapshot_public_api.py > tests/data/public_api.json + +Only names that really import in the running version are recorded, so a doc +snippet that was already broken does not become a requirement. +""" + +from __future__ import annotations + +import importlib +import inspect +import json +import re +import sys +from importlib import metadata +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +SOURCES = ("README.md", "docs", "examples") + +# Underscore members that the docs tell users to call (README "Built-in async +# CRUD helpers", docs/advanced/async.md, docs/api-reference/pagination.md). +DOCUMENTED_PRIVATE = { + "RestEndpoint": ( + "_list_async", + "_retrieve_async", + "_create_async", + "_update_async", + "_destroy_async", + "_get_async_engine", + ), +} + +_IMPORT = re.compile( + r"^\s*from\s+(lightapi[\w.]*)\s+import\s+(\([^)]*\)|[^\n]+)", re.MULTILINE +) + + +def documented_imports() -> set[tuple[str, str]]: + pairs: set[tuple[str, str]] = set() + for source in SOURCES: + path = REPO / source + files = [path] if path.is_file() else sorted(path.rglob("*")) + for file in files: + if file.suffix in {".md", ".py"}: + pairs |= _imports_in(file.read_text(encoding="utf-8")) + return pairs + + +def _imports_in(text: str) -> set[tuple[str, str]]: + pairs = set() + for module, names in _IMPORT.findall(text): + for chunk in names.strip("()").split(","): + name = chunk.split("#")[0].split(" as ")[0].strip() + if name.isidentifier(): + pairs.add((module, name)) + return pairs + + +def _parameters(callable_: Any) -> list[list[Any]] | None: + try: + signature = inspect.signature(callable_) + except (TypeError, ValueError): + return None + return [ + [p.name, p.kind.name, p.default is not inspect.Parameter.empty] + for p in signature.parameters.values() + if p.name not in {"self", "cls", "mcs"} + ] + + +def _own(member: Any) -> bool: + return (getattr(member, "__module__", "") or "").startswith("lightapi") + + +def signatures_of(name: str, obj: Any) -> dict[str, list[list[Any]]]: + found: dict[str, list[list[Any]]] = {} + if inspect.isclass(obj): + wanted = [m for m in vars(obj) if not m.startswith("_")] + wanted += ["__init__", *DOCUMENTED_PRIVATE.get(name, ())] + for member_name in wanted: + member = inspect.getattr_static(obj, member_name, None) + member = getattr(member, "__func__", member) + if inspect.isfunction(member) and _own(member): + found[f"{name}.{member_name}"] = _parameters(member) or [] + elif inspect.isfunction(obj) and _own(obj): + found[name] = _parameters(obj) or [] + return found + + +def snapshot() -> dict[str, Any]: + imports, signatures = [], {} + for module_name, name in sorted(documented_imports()): + try: + obj = getattr(importlib.import_module(module_name), name) + except (ImportError, AttributeError): + continue + imports.append([module_name, name]) + signatures.update(signatures_of(name, obj)) + + import lightapi + + in_repo = REPO in Path(lightapi.__file__).resolve().parents + origin = "working tree" if in_repo else "installed package" + return { + "recorded_from": f"lightapi {metadata.version('lightapi')} ({origin})", + "imports": imports, + "signatures": dict(sorted(signatures.items())), + } + + +if __name__ == "__main__": + json.dump(snapshot(), sys.stdout, indent=2) + sys.stdout.write("\n") diff --git a/tools/e2e/Dockerfile.local b/tools/e2e/Dockerfile.local new file mode 100644 index 0000000..0d09734 --- /dev/null +++ b/tools/e2e/Dockerfile.local @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 +# The repository Dockerfile with one difference: it installs the wheel built from this +# checkout (uv build -> dist/) instead of the published release. Used by +# tools/e2e/scenarios/16_docker.sh only, with a build context that holds just +# dist/ and docker/ (the repository .dockerignore excludes dist). +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + LIGHTAPI_CONFIG=/app/lightapi.yaml \ + LIGHTAPI_HOST=0.0.0.0 \ + LIGHTAPI_PORT=8000 \ + LIGHTAPI_LOG_LEVEL=info + +WORKDIR /app + +COPY dist/*.whl /tmp/dist/ +RUN pip install "$(ls /tmp/dist/*.whl)[async]" psycopg2-binary && rm -rf /tmp/dist /root/.cache + +COPY docker/entrypoint.py /app/entrypoint.py + +RUN groupadd --system --gid 1001 lightapi \ + && useradd --system --uid 1001 --gid lightapi --home-dir /app --no-create-home lightapi \ + && chown -R lightapi:lightapi /app +USER lightapi + +EXPOSE 8000 + +# The real image's probe and timeout; only the interval and start period are shorter +# (2 s and 5 s instead of 30 s and 10 s) so the scenario can read `healthy` within seconds. +HEALTHCHECK --interval=2s --timeout=3s --start-period=5s --retries=3 \ + 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"] diff --git a/tools/e2e/README.md b/tools/e2e/README.md new file mode 100644 index 0000000..8ff14fc --- /dev/null +++ b/tools/e2e/README.md @@ -0,0 +1,35 @@ +# End-to-end curl suite + +Real server process, real `curl`, throwaway databases. `tools/e2e/run_all.sh` runs every +`scenarios/*.sh` against a SQLite file, then again against a Postgres container +(`lightapi-e2e-pg`, 127.0.0.1:55432) for the scenarios tagged `pg`. Exit 0 only when every +step of every run passed. + + tools/e2e/run_all.sh # everything (needs docker for the [pg] runs) + tools/e2e/run_all.sh auth # scenarios whose name contains "auth" + E2E_NO_DOCKER=1 tools/e2e/run_all.sh # skip the [pg] runs instead of failing them + E2E_DOCKER=1 tools/e2e/run_all.sh docker # build and probe the container image + tools/e2e/scenarios/04_auth_jwt.sh # one scenario alone, SQLite, port 18100 + +Needs: jq, curl, uv, sqlite3, psql, docker. The [pg] runs also need both Postgres drivers in +the uv environment: the scripts run `uv run --frozen --extra dev --extra async`, which gives +asyncpg from the lock; psycopg2 is in no manifest, so install it once with +`uv pip install psycopg2-binary`. `run_all.sh` checks both before the first run. Logs, +response bodies and databases land in `tools/e2e/.out//` (gitignored). + +To run the whole suite on the other supported interpreter, point uv at a second +environment (built from the same lock) and install the driver there too: + + uv pip install --python .venv310/bin/python psycopg2-binary # after the first run created it + UV_PYTHON=3.10 UV_PROJECT_ENVIRONMENT=$PWD/.venv310 tools/e2e/run_all.sh + +Each script is self-contained: it starts its app from `apps/.py` (or +`python -m lightapi serve` on `configs/.yaml`), waits for `/healthz`, runs its steps +with an inline `expect -- `, and kills the server on +exit. Scripts share no helper file on purpose. + +Environment the scripts read: `E2E_PORT` (default 18100), `E2E_DB_URL` (default a SQLite +file under `.out/`), `E2E_PSQL` (set by `run_all.sh` for the [pg] runs). + +Known gaps: none. A step that pins a gap that cannot be fixed here would say so in this +section. diff --git a/tools/e2e/apps/01_crud_sync.py b/tools/e2e/apps/01_crud_sync.py new file mode 100644 index 0000000..2d913a0 --- /dev/null +++ b/tools/e2e/apps/01_crud_sync.py @@ -0,0 +1,26 @@ +"""e2e app for scenario 01: plain CRUD on a sync engine.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import Field, LightApi, RestEndpoint + + +class Book(RestEndpoint): + title: str = Field(min_length=1) + author: str = Field(min_length=1) + pages: int | None = Field(default=None) + + class Meta: + table = "e2e01_books" + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/books": Book}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/02_crud_async.py b/tools/e2e/apps/02_crud_async.py new file mode 100644 index 0000000..0a8346a --- /dev/null +++ b/tools/e2e/apps/02_crud_async.py @@ -0,0 +1,26 @@ +"""e2e app for scenario 02: the same CRUD endpoint on an async engine.""" + +import os + +from sqlalchemy.ext.asyncio import create_async_engine + +from lightapi import Field, LightApi, RestEndpoint + + +class Book(RestEndpoint): + title: str = Field(min_length=1) + author: str = Field(min_length=1) + pages: int | None = Field(default=None) + + class Meta: + table = "e2e02_books" + + +def main() -> None: + app = LightApi(engine=create_async_engine(os.environ["E2E_DB_URL"]), mode="async") + app.register({"/books": Book}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/03_mixins_serializer.py b/tools/e2e/apps/03_mixins_serializer.py new file mode 100644 index 0000000..8727cb2 --- /dev/null +++ b/tools/e2e/apps/03_mixins_serializer.py @@ -0,0 +1,100 @@ +"""e2e app for scenario 03: HttpMethod mixins and Serializer forms.""" + +import os +from typing import ClassVar + +from sqlalchemy import create_engine + +from lightapi import Field, HttpMethod, LightApi, RestEndpoint, Serializer + + +class ReadOnlyNote(RestEndpoint, HttpMethod.GET): + text: str = Field(min_length=1) + + class Meta: + table = "e2e03_notes" + + +class Event(RestEndpoint, HttpMethod.GET, HttpMethod.POST): + name: str = Field(min_length=1) + + class Meta: + table = "e2e03_events" + + +class PublicUser(RestEndpoint): + name: str = Field(min_length=1) + email: str = Field(min_length=1) + secret: str = Field(min_length=1) + + class Meta: + table = "e2e03_users" + serializer = Serializer(fields=["id", "name", "email"]) + + +class Order(RestEndpoint): + name: str = Field(min_length=1) + tag: str = Field(default="") + + class Meta: + table = "e2e03_orders" + serializer = Serializer(read=["id", "name", "tag"], write=["id", "name"]) + + +class NameOnly(Serializer): + read: ClassVar[list[str]] = ["id", "name"] + write: ClassVar[list[str]] = ["id", "name"] + + +class Alpha(RestEndpoint): + name: str = Field(min_length=1) + extra: str = Field(default="") + + class Meta: + table = "e2e03_alpha" + serializer = NameOnly + + +class Beta(RestEndpoint): + name: str = Field(min_length=1) + extra: str = Field(default="") + + class Meta: + table = "e2e03_beta" + serializer = NameOnly + + +class InboxReader(RestEndpoint, HttpMethod.GET): + text: str = Field(min_length=1) + + class Meta: + table = "e2e03_inbox_read" + + +class InboxWriter(RestEndpoint, HttpMethod.POST): + text: str = Field(min_length=1) + + class Meta: + table = "e2e03_inbox_write" + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register( + { + "/notes": ReadOnlyNote, + "/events": Event, + "/users": PublicUser, + "/orders": Order, + "/alpha": Alpha, + "/beta": Beta, + } + ) + # Two classes on one path, split by method: Starlette routes by verb. + app.register({"/inbox": InboxReader}) + app.register({"/inbox": InboxWriter}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/04_auth_jwt.py b/tools/e2e/apps/04_auth_jwt.py new file mode 100644 index 0000000..76e8057 --- /dev/null +++ b/tools/e2e/apps/04_auth_jwt.py @@ -0,0 +1,69 @@ +"""e2e app for scenario 04: JWT login, permissions per method, extra claims.""" + +import os + +from sqlalchemy import create_engine +from validators import check_login + +from lightapi import ( + AllowAny, + Authentication, + Field, + IsAdminUser, + IsAuthenticated, + JWTAuthentication, + LightApi, + RestEndpoint, +) + + +class Secret(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + table = "e2e04_secrets" + authentication = Authentication( + backend=JWTAuthentication, + permission=IsAuthenticated, + jwt_expiration=120, + jwt_extra_claims=["is_admin"], + ) + + +class AdminOnly(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + table = "e2e04_admin" + authentication = Authentication( + backend=JWTAuthentication, permission=IsAdminUser + ) + + +class Mixed(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + table = "e2e04_mixed" + authentication = Authentication( + backend=JWTAuthentication, + permission={ + "GET": AllowAny, + "POST": IsAuthenticated, + "DELETE": IsAdminUser, + }, + ) + + +def main() -> None: + # Secret first: the login route builds its tokens with the first JWT backend, + # so jwt_expiration and jwt_extra_claims are the ones the token shows. + app = LightApi( + engine=create_engine(os.environ["E2E_DB_URL"]), login_validator=check_login + ) + app.register({"/secrets": Secret, "/admin": AdminOnly, "/mixed": Mixed}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/05_auth_basic.py b/tools/e2e/apps/05_auth_basic.py new file mode 100644 index 0000000..b30fb7d --- /dev/null +++ b/tools/e2e/apps/05_auth_basic.py @@ -0,0 +1,40 @@ +"""e2e app for scenario 05: BasicAuthentication with its own validate_credentials.""" + +import os + +from sqlalchemy import create_engine +from validators import check_login + +from lightapi import ( + Authentication, + BasicAuthentication, + Field, + IsAuthenticated, + LightApi, + RestEndpoint, +) + + +class E2EBasic(BasicAuthentication): + def validate_credentials(self, username: str, password: str) -> dict | None: + return check_login(username, password) + + +class Ledger(RestEndpoint): + entry: str = Field(min_length=1) + + class Meta: + table = "e2e05_ledger" + authentication = Authentication(backend=E2EBasic, permission=IsAuthenticated) + + +def main() -> None: + # No login_validator on the app: the subclass's validate_credentials is what + # the guard and the login route must call. + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/ledger": Ledger}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/06_filtering.py b/tools/e2e/apps/06_filtering.py new file mode 100644 index 0000000..b33c84d --- /dev/null +++ b/tools/e2e/apps/06_filtering.py @@ -0,0 +1,44 @@ +"""e2e app for scenario 06: field, search and ordering filters with page pagination.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import ( + Field, + FieldFilter, + Filtering, + LightApi, + OrderingFilter, + Pagination, + RestEndpoint, + SearchFilter, +) + + +class Book(RestEndpoint): + title: str = Field(min_length=1) + genre: str = Field(min_length=1) + price: float = Field() + pages: int = Field() + published: bool = Field(default=True) + + class Meta: + table = "e2e06_books" + filtering = Filtering( + backends=[FieldFilter, SearchFilter, OrderingFilter], + fields=["genre", "published", "pages", "price"], + search=["title"], + ordering=["price", "pages"], + ) + pagination = Pagination(page_size=5) + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/books": Book}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/07_pagination.py b/tools/e2e/apps/07_pagination.py new file mode 100644 index 0000000..f11cfa5 --- /dev/null +++ b/tools/e2e/apps/07_pagination.py @@ -0,0 +1,33 @@ +"""e2e app for scenario 07: page-number and cursor pagination.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import Field, LightApi, Pagination, RestEndpoint + + +class PageItem(RestEndpoint): + label: str = Field(min_length=1) + + class Meta: + table = "e2e07_page_items" + pagination = Pagination(page_size=2) + + +class CursorItem(RestEndpoint): + label: str = Field(min_length=1) + + class Meta: + table = "e2e07_cursor_items" + pagination = Pagination(style="cursor", page_size=2) + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/pages": PageItem, "/cursor": CursorItem}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/08_queryset.py b/tools/e2e/apps/08_queryset.py new file mode 100644 index 0000000..a23e5cd --- /dev/null +++ b/tools/e2e/apps/08_queryset.py @@ -0,0 +1,62 @@ +"""e2e app for scenario 08: static, sync and async queryset hooks on one async app.""" + +import os + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import create_async_engine +from starlette.requests import Request + +from lightapi import Field, LightApi, RestEndpoint + + +class StaticArticle(RestEndpoint): + title: str = Field(min_length=1) + published: bool = Field(default=False) + + class Meta: + table = "e2e08_static" + + +class SyncArticle(RestEndpoint): + title: str = Field(min_length=1) + published: bool = Field(default=False) + + class Meta: + table = "e2e08_sync" + + def queryset(self, request: Request): + model = type(self)._model_class + return select(model).where(model.published.is_(True)) + + +class AsyncArticle(RestEndpoint): + title: str = Field(min_length=1) + published: bool = Field(default=False) + words: int = Field(default=0) + + class Meta: + table = "e2e08_async" + + async def queryset(self, request: Request): + model = type(self)._model_class + stmt = select(model).where(model.published.is_(True)) + minimum = request.query_params.get("min_words") + if minimum is not None: + stmt = stmt.where(model.words >= int(minimum)) + return stmt + + +def main() -> None: + app = LightApi(engine=create_async_engine(os.environ["E2E_DB_URL"]), mode="async") + app.register( + {"/static": StaticArticle, "/sync": SyncArticle, "/async": AsyncArticle} + ) + # The static form needs the mapped class, which exists only after register(). + StaticArticle.queryset = select(StaticArticle._model_class).where( + StaticArticle._model_class.published.is_(True) + ) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/09_middleware.py b/tools/e2e/apps/09_middleware.py new file mode 100644 index 0000000..dbf7aca --- /dev/null +++ b/tools/e2e/apps/09_middleware.py @@ -0,0 +1,56 @@ +"""e2e app for scenario 09: sync and async middleware, short-circuit, CORS.""" + +import os + +from sqlalchemy.ext.asyncio import create_async_engine +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from lightapi import Field, LightApi, Middleware, RestEndpoint + + +class Stamp(Middleware): + def process(self, request: Request, response: Response | None) -> Response | None: + if response is None: + print("hook pre:stamp", flush=True) + return None + response.headers["x-e2e-stamp"] = "yes" + print("hook post:stamp", flush=True) + return response + + +class Trace(Middleware): + async def process( + self, request: Request, response: Response | None + ) -> Response | None: + print("hook pre:trace" if response is None else "hook post:trace", flush=True) + return response + + +class Teapot(Middleware): + def process(self, request: Request, response: Response | None) -> Response | None: + if response is None and request.headers.get("x-teapot"): + return JSONResponse({"detail": "teapot"}, status_code=418) + return response + + +class Item(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + table = "e2e09_items" + + +def main() -> None: + app = LightApi( + engine=create_async_engine(os.environ["E2E_DB_URL"]), + mode="async", + middlewares=[Stamp, Trace, Teapot], + cors_origins=["http://e2e.local"], + ) + app.register({"/items": Item}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/10_background.py b/tools/e2e/apps/10_background.py new file mode 100644 index 0000000..75c486a --- /dev/null +++ b/tools/e2e/apps/10_background.py @@ -0,0 +1,47 @@ +"""e2e app for scenario 10: self.background() with a sync and an async function.""" + +import asyncio +import json +import os +from pathlib import Path + +from sqlalchemy.ext.asyncio import create_async_engine +from starlette.requests import Request + +from lightapi import Field, LightApi, RestEndpoint + +MARKS = Path(os.environ["E2E_OUT"]) / "marks" + + +def mark_sync(item_id: int) -> None: + (MARKS / f"sync-{item_id}").write_text(str(item_id)) + + +async def mark_async(item_id: int) -> None: + await asyncio.sleep(0.05) + (MARKS / f"async-{item_id}").write_text(str(item_id)) + + +class Job(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + table = "e2e10_jobs" + + async def post(self, request: Request): + response = await self._create_async(json.loads(await request.body())) + item_id = json.loads(response.body)["id"] + self.background(mark_sync, item_id) + self.background(mark_async, item_id) + return response + + +def main() -> None: + MARKS.mkdir(parents=True, exist_ok=True) + app = LightApi(engine=create_async_engine(os.environ["E2E_DB_URL"]), mode="async") + app.register({"/jobs": Job}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/11_reflection.py b/tools/e2e/apps/11_reflection.py new file mode 100644 index 0000000..906372e --- /dev/null +++ b/tools/e2e/apps/11_reflection.py @@ -0,0 +1,31 @@ +"""e2e app for scenario 11: endpoints mapped onto tables that already exist.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import Field, LightApi, RestEndpoint + + +class Product(RestEndpoint): + class Meta: + table = "e2e11_products" + reflect = True + + +class Doc(RestEndpoint): + notes: str = Field(default="") + + class Meta: + table = "e2e11_docs" + reflect = "partial" + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/products": Product, "/docs": Doc}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/11_reflection_missing.py b/tools/e2e/apps/11_reflection_missing.py new file mode 100644 index 0000000..6df9934 --- /dev/null +++ b/tools/e2e/apps/11_reflection_missing.py @@ -0,0 +1,23 @@ +"""e2e app for scenario 11: reflecting a table that does not exist refuses to start.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import LightApi, RestEndpoint + + +class Ghost(RestEndpoint): + class Meta: + table = "e2e11_ghost" + reflect = True + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/ghost": Ghost}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/13_rate_limit.py b/tools/e2e/apps/13_rate_limit.py new file mode 100644 index 0000000..cb54242 --- /dev/null +++ b/tools/e2e/apps/13_rate_limit.py @@ -0,0 +1,47 @@ +"""e2e app for scenario 13: the login route is rate limited, CRUD routes are not.""" + +import os + +from sqlalchemy import create_engine +from validators import check_login + +from lightapi import ( + Authentication, + Field, + IsAuthenticated, + JWTAuthentication, + LightApi, + RateLimiter, + RestEndpoint, +) + + +class Secret(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + table = "e2e13_secrets" + authentication = Authentication( + backend=JWTAuthentication, permission=IsAuthenticated + ) + + +class Public(RestEndpoint): + text: str = Field(min_length=1) + + class Meta: + table = "e2e13_public" + + +def main() -> None: + app = LightApi( + engine=create_engine(os.environ["E2E_DB_URL"]), + login_validator=check_login, + rate_limiter=RateLimiter(requests_per_minute=3), + ) + app.register({"/secrets": Secret, "/public": Public}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/14_cache_fallback.py b/tools/e2e/apps/14_cache_fallback.py new file mode 100644 index 0000000..8bc454f --- /dev/null +++ b/tools/e2e/apps/14_cache_fallback.py @@ -0,0 +1,25 @@ +"""e2e app for scenario 14: Meta.cache with no Redis behind it.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import Cache, Field, LightApi, RestEndpoint + + +class Report(RestEndpoint): + title: str = Field(min_length=1) + + class Meta: + table = "e2e14_reports" + cache = Cache(ttl=5) + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/reports": Report}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/15_concurrency.py b/tools/e2e/apps/15_concurrency.py new file mode 100644 index 0000000..d91c8c3 --- /dev/null +++ b/tools/e2e/apps/15_concurrency.py @@ -0,0 +1,25 @@ +"""e2e app for scenario 15: optimistic locking under ten parallel writers.""" + +import os + +from sqlalchemy import create_engine + +from lightapi import Field, LightApi, RestEndpoint + + +class Counter(RestEndpoint): + label: str = Field(min_length=1) + hits: int = Field(default=0) + + class Meta: + table = "e2e15_counters" + + +def main() -> None: + app = LightApi(engine=create_engine(os.environ["E2E_DB_URL"])) + app.register({"/counters": Counter}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/apps/17_field_options.py b/tools/e2e/apps/17_field_options.py new file mode 100644 index 0000000..6927aef --- /dev/null +++ b/tools/e2e/apps/17_field_options.py @@ -0,0 +1,44 @@ +"""e2e app for scenario 17: Field column options and the unique-violation path.""" + +import os +from decimal import Decimal + +from sqlalchemy import create_engine, event + +from lightapi import Field, LightApi, RestEndpoint + + +class Category(RestEndpoint): + name: str = Field(min_length=1) + + class Meta: + table = "e2e17_categories" + + +class Product(RestEndpoint): + sku: str = Field(min_length=1, unique=True) + name: str = Field(min_length=1, index=True) + price: Decimal = Field(decimal_places=2) + internal_note: str = Field(default="", exclude=True) + category_id: int | None = Field(default=None, foreign_key="e2e17_categories.id") + + class Meta: + table = "e2e17_products" + + +def main() -> None: + engine = create_engine(os.environ["E2E_DB_URL"]) + if engine.dialect.name == "sqlite": + # SQLite ignores foreign keys unless each connection asks; Postgres always + # enforces them, so both runs refuse the DELETE of a referenced category. + @event.listens_for(engine, "connect") + def _enforce_foreign_keys(dbapi_connection, _record) -> None: + dbapi_connection.execute("PRAGMA foreign_keys=ON") + + app = LightApi(engine=engine) + app.register({"/categories": Category, "/products": Product}) + app.run(host="127.0.0.1", port=int(os.environ["E2E_PORT"])) + + +if __name__ == "__main__": + main() diff --git a/tools/e2e/configs/12_yaml_async.yaml b/tools/e2e/configs/12_yaml_async.yaml new file mode 100644 index 0000000..e859e93 --- /dev/null +++ b/tools/e2e/configs/12_yaml_async.yaml @@ -0,0 +1,13 @@ +--- +# Scenario 12: an async document, served by `lightapi serve` (B6-001). +database: + url: "@DB@" + +mode: async + +endpoints: + - route: /asyncbooks + fields: + title: + type: str + min_length: 1 diff --git a/tools/e2e/configs/12_yaml_badkey.yaml b/tools/e2e/configs/12_yaml_badkey.yaml new file mode 100644 index 0000000..1ad2d61 --- /dev/null +++ b/tools/e2e/configs/12_yaml_badkey.yaml @@ -0,0 +1,15 @@ +--- +# Scenario 12: a key the format does not know (`defaults.filtering`) must refuse +# to start instead of silently serving an app without the feature. +database: + url: "sqlite:///:memory:" + +defaults: + filtering: + fields: [genre] + +endpoints: + - route: /books + fields: + title: + type: str diff --git a/tools/e2e/configs/12_yaml_serve.yaml b/tools/e2e/configs/12_yaml_serve.yaml new file mode 100644 index 0000000..f63faa1 --- /dev/null +++ b/tools/e2e/configs/12_yaml_serve.yaml @@ -0,0 +1,76 @@ +--- +# Scenario 12: everything the YAML format offers, served by `lightapi serve`. +# @DB@ is replaced by the script with the run's SQLite URL. +database: + url: "@DB@" + +mode: sync + +auth: + auth_path: /auth + login_validator: validators.check_login + +defaults: + pagination: + style: page_number + page_size: 3 + +endpoints: + - route: /books + fields: + title: + type: str + min_length: 1 + max_length: 40 + genre: + type: str + default: general + pages: + type: int + optional: true + rating: + type: float + ge: 0 + le: 5 + default: 0 + meta: + filtering: + backends: [FieldFilter, SearchFilter, OrderingFilter] + fields: [genre] + search: [title] + ordering: [pages] + + - route: /notes + fields: + text: + type: str + min_length: 1 + meta: + methods: [GET] + + - route: /secrets + fields: + text: + type: str + meta: + authentication: + backend: JWTAuthentication + permission: IsAuthenticated + + - route: /mixed + fields: + text: + type: str + meta: + authentication: + backend: JWTAuthentication + methods: + GET: + authentication: + permission: AllowAny + POST: + authentication: + permission: IsAuthenticated + DELETE: + authentication: + permission: IsAdminUser diff --git a/tools/e2e/configs/16_docker.yaml b/tools/e2e/configs/16_docker.yaml new file mode 100644 index 0000000..07bb808 --- /dev/null +++ b/tools/e2e/configs/16_docker.yaml @@ -0,0 +1,9 @@ +--- +# Scenario 16: the document the container serves; mounted at /app/lightapi.yaml. +database: + url: "sqlite:////tmp/e2e.sqlite" + +endpoints: + - route: /books + fields: + title: {type: str, min_length: 1} diff --git a/tools/e2e/run_all.sh b/tools/e2e/run_all.sh new file mode 100755 index 0000000..fa758b4 --- /dev/null +++ b/tools/e2e/run_all.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Runs every tools/e2e/scenarios/*.sh: SQLite first, then again on a throwaway Postgres +# for the scenarios tagged `pg`. Exit 0 only when every run passed. +set -u +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +E2E=$ROOT/tools/e2e +OUT=$E2E/.out +mkdir -p "$OUT" +FILTER=${1:-} + +for tool in jq curl uv sqlite3 psql; do + command -v "$tool" >/dev/null || { + echo "missing tool: $tool" + exit 1 + } +done + +# The [pg] runs need both Postgres drivers in the interpreter the scripts use. asyncpg comes +# from the `async` extra; psycopg2 is not in the manifest, so it must be installed by hand +# (`uv pip install psycopg2-binary`). This first `uv run` also builds a fresh environment +# (UV_PYTHON=3.10 ...) before any server has to answer within its start window. +if [ "${E2E_NO_DOCKER:-}" != 1 ]; then + uv run --frozen --extra dev --extra async --directory "$ROOT" python -c "import asyncpg, psycopg2" >"$OUT/drivers.log" 2>&1 || { + echo "the [pg] runs need asyncpg and psycopg2 in the uv environment:" + tail -1 "$OUT/drivers.log" + echo "install with: uv pip install psycopg2-binary (E2E_NO_DOCKER=1 skips the [pg] runs)" + exit 1 + } +fi + +HAVE_DOCKER=0 +command -v docker >/dev/null && docker info >/dev/null 2>&1 && HAVE_DOCKER=1 +PG_NAME=lightapi-e2e-pg +PG_PORT=55432 +PG_PASSWORD=e2e # a throwaway container on loopback; kept out of the URLs so scanners stay quiet +PG_READY=0 +if [ $HAVE_DOCKER = 1 ]; then + docker rm -f $PG_NAME >/dev/null 2>&1 || true + docker run -d --rm --name $PG_NAME -e POSTGRES_PASSWORD="$PG_PASSWORD" -p 127.0.0.1:$PG_PORT:5432 postgres:16 >/dev/null + trap 'docker rm -f $PG_NAME >/dev/null 2>&1' EXIT + export E2E_PG_URL=postgresql+psycopg2://postgres:$PG_PASSWORD@127.0.0.1:$PG_PORT/postgres + export E2E_PG_ASYNC_URL=postgresql+asyncpg://postgres:$PG_PASSWORD@127.0.0.1:$PG_PORT/postgres + export E2E_PSQL="psql postgresql://postgres:$PG_PASSWORD@127.0.0.1:$PG_PORT/postgres" + # pg_isready answers before initdb's restart; a real query from the host is the proof. + for _ in $(seq 1 60); do + $E2E_PSQL -qAt -c 'select 1' >/dev/null 2>&1 && PG_READY=1 && break + sleep 0.5 + done + [ $PG_READY = 1 ] || echo "postgres container did not become ready; [pg] runs will fail" +fi + +port=18100 +ok=0 +fail=0 +skipped=0 + +port_is_free() { ! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; } + +run_one() { # run_one