lenapi 0.1.3__tar.gz → 0.1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {lenapi-0.1.3 → lenapi-0.1.4.1}/PKG-INFO +36 -5
  2. {lenapi-0.1.3 → lenapi-0.1.4.1}/README.md +35 -4
  3. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/__init__.py +1 -1
  4. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/application.py +2 -1
  5. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/authentication.py +51 -8
  6. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/models.py +18 -3
  7. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/request.py +6 -1
  8. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/security.py +19 -7
  9. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/security_store.py +5 -1
  10. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/PKG-INFO +36 -5
  11. {lenapi-0.1.3 → lenapi-0.1.4.1}/pyproject.toml +1 -1
  12. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_framework_features.py +81 -1
  13. {lenapi-0.1.3 → lenapi-0.1.4.1}/LICENSE +0 -0
  14. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/admin.py +0 -0
  15. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/auth.py +0 -0
  16. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/authorization.py +0 -0
  17. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/background.py +0 -0
  18. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/cli.py +0 -0
  19. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/concurrency.py +0 -0
  20. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/database.py +0 -0
  21. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/dependencies.py +0 -0
  22. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/docs_guard.py +0 -0
  23. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/logging.py +0 -0
  24. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/middleware.py +0 -0
  25. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/model.py +0 -0
  26. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/mongodb.py +0 -0
  27. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/openapi.py +0 -0
  28. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/parameters.py +0 -0
  29. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/project.py +0 -0
  30. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/response.py +0 -0
  31. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/sentinels.py +0 -0
  32. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/service.py +0 -0
  33. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/throttling.py +0 -0
  34. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/transaction.py +0 -0
  35. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/SOURCES.txt +0 -0
  36. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/dependency_links.txt +0 -0
  37. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/entry_points.txt +0 -0
  38. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/requires.txt +0 -0
  39. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/top_level.txt +0 -0
  40. {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi_bootstrap.py +0 -0
  41. {lenapi-0.1.3 → lenapi-0.1.4.1}/setup.cfg +0 -0
  42. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_admin_console.py +0 -0
  43. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_cli_settings_recovery.py +0 -0
  44. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_docs_guard.py +0 -0
  45. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_media_upload.py +0 -0
  46. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_mongodb.py +0 -0
  47. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_platform_services.py +0 -0
  48. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_project_isolation.py +0 -0
  49. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_security_patch.py +0 -0
  50. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_sentinels.py +0 -0
  51. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_throttle.py +0 -0
  52. {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_user_model_extension.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lenapi
3
- Version: 0.1.3
3
+ Version: 0.1.4.1
4
4
  Summary: A modern ASGI framework with Pydantic validation, sync and async database access, WebSockets, authentication, and integrated API documentation.
5
5
  Author: Maekandex Communication Limitation
6
6
  Author-email: "Ifeanyi M. Onovo" <ifeanyi@maekandex.com.ng>
@@ -425,7 +425,7 @@ app.include_router(api)
425
425
 
426
426
  Rate strings accept seconds, minutes, hours, or days, for example `"10/second"`, `"60/minute"`, and `"1000/day"`. Throttling uses a fixed window that starts with the first request. `ip` counts every request from the client address before authentication. `anonymous` counts public calls and, on routes using `auth=`, all requests from that IP before credentials are verified. This intentionally includes successful logins so repeated password checks are stopped before they consume CPU. `authenticated` counts successful route-level authentication by a stable identity returned from `auth=`. Routes using `BasicAuth` receive a default `ip` limit of 10 requests per minute if no pre-auth `ip` or `anonymous` limit is configured. Set `throttle={}` to deliberately disable the inherited and default limit. Route and router `throttle=` values otherwise override inherited limits; an empty mapping disables inherited limits for that route or router. Endpoint-only auth declared with `Depends(...)` does not supply the identity to the authenticated quota; attach it with `auth=` when you want identity-based quotas.
427
427
 
428
- When a quota is exceeded, LenAPI returns `429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Quota counters use the same security backend as IP cooldowns. Choose `SECURITY_STORAGE = "database"` or `"redis"` in project `settings.py`. Database mode stores state in the internal `lenapi_security_store` table or MongoDB collection, initialized by migrations. Redis mode uses `REDIS_URL` and requires `pip install 'lenapi[redis]'` plus a running Redis server. The generated settings default to database storage and include `REDIS_URL = "redis://127.0.0.1:6379/0"` for switching to Redis. Shared database or Redis storage coordinates limits across workers. `SECURITY_STORAGE = "memory"` keeps counters in each process, so use it only for single-process development. LenAPI uses the ASGI connection's client address and does not trust forwarded-IP headers automatically; configure trusted proxy handling at your deployment edge before relying on IP quotas behind a proxy.
428
+ When a quota is exceeded, LenAPI returns `429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Quota counters use the same security backend as IP cooldowns. Choose `SECURITY_STORAGE = "database"` or `"redis"` in project `settings.py`. Database mode stores state in the internal `lenapi_security_store` table or MongoDB collection, initialized by migrations. Redis mode uses `REDIS_URL` and requires `python -m pip install 'lenapi[redis]'` plus a running Redis server. Run the install command with the same Python environment that starts LenAPI; keep the quotes around the extra in zsh. The generated settings default to database storage and include `REDIS_URL = "redis://127.0.0.1:6379/0"` for switching to Redis. Shared database or Redis storage coordinates limits across workers. `SECURITY_STORAGE = "memory"` keeps counters in each process, so use it only for single-process development. LenAPI uses the ASGI connection's client address and does not trust forwarded-IP headers automatically; configure trusted proxy handling at your deployment edge before relying on IP quotas behind a proxy.
429
429
 
430
430
  For example, app-level limits are inherited by routes that do not set their own throttle. A route-specific mapping replaces the inherited mapping, so repeat any global scopes that you still want on that route:
431
431
 
@@ -1089,7 +1089,31 @@ app.include_router(public)
1089
1089
 
1090
1090
  The framework-backed JWT authenticator checks the submitted username and password against the database `User` model and its stored password hash. `POST /auth/token` accepts `{"username": "...", "password": "..."}` and returns an access token plus a refresh token. Send the access token as `Authorization: Bearer <access_token>`. `POST /auth/refresh` accepts `{"refresh_token": "..."}` and returns replacement tokens. The token and refresh routes are intentionally hidden from Swagger; protected API routes still show their Bearer scheme and Swagger's **Authorize** control. These routes have a default IP limit of 10 requests per minute, including failed credential checks; pass `throttle={"ip": "5/minute"}` to `jwt.router(...)` for a different limit. In production, use shared `SECURITY_STORAGE = "database"` or `"redis"` for refresh replay protection across workers. Refresh tokens are single-use; the in-memory fallback coordinates only within one process.
1091
1091
 
1092
- For a custom user table, create the JWT dependency with `authentication("jwt", user_model=YourUser)`. Basic and JWT can use your model as long as it has the selected identifier column and a `password` column containing LenAPI-compatible password hashes. For Basic, select the identifier with `BASIC_AUTH_FIELD`. Or configure `JWTAuth` with an `authenticate(username, password)` callback. That callback must verify a password hash and return the user's string ID, `(string_id, claims)`, or `None`. `JWTAuth` signs and verifies HS256 tokens, checks expiry and configured issuer, and rejects refresh tokens when used as access tokens. Keep the signing key secret and serve credentials only over HTTPS.
1092
+ For a custom user table, create the JWT dependency with `authentication("jwt", user_model=YourUser)`. Basic and JWT can use your model as long as it has the selected identifier column and a `password` column containing LenAPI-compatible password hashes. For Basic, select the identifier with `BASIC_AUTH_FIELD`. JWT defaults to the `username` column; pass `identifier_field="email"` (or another model column) to use a different login identifier. That field name is also used in the `/auth/token` JSON body. Pass the actual `LENAPI` instance with `app=app` when you want refresh-token replay protection to use its shared security store; do not pass a `Router` there.
1093
+
1094
+ For example, a custom model with `email` and a hashed `password` can sign in using `{"email": "ada@example.com", "password": "..."}`:
1095
+
1096
+ ```python
1097
+ from lenapi_bootstrap import create_app
1098
+ from lenapi import Depends, authentication
1099
+
1100
+ app = create_app(__file__, __package__)
1101
+ from app.models import Customer
1102
+ auth = authentication(
1103
+ "jwt",
1104
+ user_model=Customer,
1105
+ identifier_field="email",
1106
+ app=app,
1107
+ )
1108
+
1109
+ app.include_router(auth.router()) # POST /auth/token and POST /auth/refresh
1110
+
1111
+ @app.get("/profile", auth=auth)
1112
+ def profile(identity=Depends(auth)):
1113
+ return {"customer_id": identity["sub"]}
1114
+ ```
1115
+
1116
+ `Customer` must define an `email` column and a `password` column containing a LenAPI-compatible password hash. Your registration or provisioning flow is responsible for collecting the email and storing the password hash. `JWTAuth` signs and verifies HS256 tokens, checks expiry and configured issuer, and rejects refresh tokens when used as access tokens. Keep the signing key secret and serve credentials only over HTTPS.
1093
1117
 
1094
1118
  ## API-key authentication
1095
1119
 
@@ -1903,7 +1927,7 @@ student = Student.create(
1903
1927
 
1904
1928
  `DateField`, `TimeField`, and `DateTimeField` default to the current UTC date or time when no value or explicit default is supplied. Pass `auto_add_now=False` to disable this behavior, or provide your own `default`. `DurationField(weeks=1)` stores a fixed seven-day `timedelta`; add it to a timestamp when calculating an expiry date. Months and years are calendar intervals and should use calendar-aware date arithmetic.
1905
1929
 
1906
- `PositiveIntegerField` and `PositiveBigIntegerField` reject negative values in LenAPI and add a named database `CHECK` constraint, so direct SQL writes are protected too. `EmailField`, `PhoneField`, `IPField`, `URLField`, and `UUIDField` validate values when a model instance is saved. `PhoneField` accepts a leading `+` followed by country code and digits, up to 18 characters total. `IPField` accepts IPv4 and IPv6. `URLField` accepts `http://`, `https://`, and `www.` URLs. Customize messages with `error_messages`, for example `email = model.EmailField(error_messages={"email": "This is not an email"})`; `unique` is also a supported message key for a unique field.
1930
+ `PositiveIntegerField` and `PositiveBigIntegerField` reject negative values in LenAPI and add a named database `CHECK` constraint, so direct SQL writes are protected too. `EmailField`, `PhoneField`, `IPField`, `URLField`, and `UUIDField` validate values when a model instance is saved. `PhoneField` accepts either a `+` country-code number or a local number beginning with `0`, up to 18 characters. Declare phone numbers as strings and send them quoted in JSON so leading zeroes are preserved; bare JSON numbers such as `08099448550` are invalid JSON. `IPField` accepts IPv4 and IPv6. `URLField` accepts `http://`, `https://`, and `www.` URLs. Customize messages with `error_messages`, for example `email = model.EmailField(error_messages={"email": "This is not an email"})`; `unique` is also a supported message key for a unique field.
1907
1931
 
1908
1932
  The base model supplies an auto-incrementing integer `id`. A model may declare its own primary key instead; LenAPI will not add a second `id` key:
1909
1933
 
@@ -2082,7 +2106,14 @@ async_total = async_page.total_count
2082
2106
  async_pages = async_page.page_count
2083
2107
  ```
2084
2108
 
2085
- `Student.get.all()` returns all rows synchronously; its async counterpart is `await Student.aget.all()`. For filtered async lists, use `await Student.afilter(...).all()`. `Student.get(...)` builds a single-row lookup; call `.get()` for the model instance or `.exists()` for a boolean. The async single-row counterpart is `await Student.aget(...)`, and its existence check is `await Student.aget(...).exists()`. Multiple matches are an error rather than an arbitrary row selection. `Student.get("field", ...).get(...)` selects one or more columns; one selected column returns a scalar and multiple columns return a mapping. This is a query projection, not a uniqueness constraint. To make the database reject duplicates, declare `unique=True` on the field.
2109
+ `Student.get.all()` returns all rows synchronously; its async counterpart is `await Student.aget.all()`. For filtered lists, use `Student.filter(...).all()` or `await Student.afilter(...).all()`. Both sync and async filtered querysets support existence checks:
2110
+
2111
+ ```python
2112
+ has_python_students = Student.filter(course="Python").exists()
2113
+ has_python_students_async = await Student.afilter(course="Python").exists()
2114
+ ```
2115
+
2116
+ `Student.get(...)` builds a single-row lookup; call `.get()` for the model instance or `.exists()` for a boolean. The async single-row counterpart is `await Student.aget(...)`, and its existence check is `await Student.aget(...).exists()`. Multiple matches are an error rather than an arbitrary row selection. `Student.get("field", ...).get(...)` selects one or more columns; one selected column returns a scalar and multiple columns return a mapping. This is a query projection, not a uniqueness constraint. To make the database reject duplicates, declare `unique=True` on the field.
2086
2117
 
2087
2118
  ### Fetch related records
2088
2119
 
@@ -373,7 +373,7 @@ app.include_router(api)
373
373
 
374
374
  Rate strings accept seconds, minutes, hours, or days, for example `"10/second"`, `"60/minute"`, and `"1000/day"`. Throttling uses a fixed window that starts with the first request. `ip` counts every request from the client address before authentication. `anonymous` counts public calls and, on routes using `auth=`, all requests from that IP before credentials are verified. This intentionally includes successful logins so repeated password checks are stopped before they consume CPU. `authenticated` counts successful route-level authentication by a stable identity returned from `auth=`. Routes using `BasicAuth` receive a default `ip` limit of 10 requests per minute if no pre-auth `ip` or `anonymous` limit is configured. Set `throttle={}` to deliberately disable the inherited and default limit. Route and router `throttle=` values otherwise override inherited limits; an empty mapping disables inherited limits for that route or router. Endpoint-only auth declared with `Depends(...)` does not supply the identity to the authenticated quota; attach it with `auth=` when you want identity-based quotas.
375
375
 
376
- When a quota is exceeded, LenAPI returns `429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Quota counters use the same security backend as IP cooldowns. Choose `SECURITY_STORAGE = "database"` or `"redis"` in project `settings.py`. Database mode stores state in the internal `lenapi_security_store` table or MongoDB collection, initialized by migrations. Redis mode uses `REDIS_URL` and requires `pip install 'lenapi[redis]'` plus a running Redis server. The generated settings default to database storage and include `REDIS_URL = "redis://127.0.0.1:6379/0"` for switching to Redis. Shared database or Redis storage coordinates limits across workers. `SECURITY_STORAGE = "memory"` keeps counters in each process, so use it only for single-process development. LenAPI uses the ASGI connection's client address and does not trust forwarded-IP headers automatically; configure trusted proxy handling at your deployment edge before relying on IP quotas behind a proxy.
376
+ When a quota is exceeded, LenAPI returns `429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Quota counters use the same security backend as IP cooldowns. Choose `SECURITY_STORAGE = "database"` or `"redis"` in project `settings.py`. Database mode stores state in the internal `lenapi_security_store` table or MongoDB collection, initialized by migrations. Redis mode uses `REDIS_URL` and requires `python -m pip install 'lenapi[redis]'` plus a running Redis server. Run the install command with the same Python environment that starts LenAPI; keep the quotes around the extra in zsh. The generated settings default to database storage and include `REDIS_URL = "redis://127.0.0.1:6379/0"` for switching to Redis. Shared database or Redis storage coordinates limits across workers. `SECURITY_STORAGE = "memory"` keeps counters in each process, so use it only for single-process development. LenAPI uses the ASGI connection's client address and does not trust forwarded-IP headers automatically; configure trusted proxy handling at your deployment edge before relying on IP quotas behind a proxy.
377
377
 
378
378
  For example, app-level limits are inherited by routes that do not set their own throttle. A route-specific mapping replaces the inherited mapping, so repeat any global scopes that you still want on that route:
379
379
 
@@ -1037,7 +1037,31 @@ app.include_router(public)
1037
1037
 
1038
1038
  The framework-backed JWT authenticator checks the submitted username and password against the database `User` model and its stored password hash. `POST /auth/token` accepts `{"username": "...", "password": "..."}` and returns an access token plus a refresh token. Send the access token as `Authorization: Bearer <access_token>`. `POST /auth/refresh` accepts `{"refresh_token": "..."}` and returns replacement tokens. The token and refresh routes are intentionally hidden from Swagger; protected API routes still show their Bearer scheme and Swagger's **Authorize** control. These routes have a default IP limit of 10 requests per minute, including failed credential checks; pass `throttle={"ip": "5/minute"}` to `jwt.router(...)` for a different limit. In production, use shared `SECURITY_STORAGE = "database"` or `"redis"` for refresh replay protection across workers. Refresh tokens are single-use; the in-memory fallback coordinates only within one process.
1039
1039
 
1040
- For a custom user table, create the JWT dependency with `authentication("jwt", user_model=YourUser)`. Basic and JWT can use your model as long as it has the selected identifier column and a `password` column containing LenAPI-compatible password hashes. For Basic, select the identifier with `BASIC_AUTH_FIELD`. Or configure `JWTAuth` with an `authenticate(username, password)` callback. That callback must verify a password hash and return the user's string ID, `(string_id, claims)`, or `None`. `JWTAuth` signs and verifies HS256 tokens, checks expiry and configured issuer, and rejects refresh tokens when used as access tokens. Keep the signing key secret and serve credentials only over HTTPS.
1040
+ For a custom user table, create the JWT dependency with `authentication("jwt", user_model=YourUser)`. Basic and JWT can use your model as long as it has the selected identifier column and a `password` column containing LenAPI-compatible password hashes. For Basic, select the identifier with `BASIC_AUTH_FIELD`. JWT defaults to the `username` column; pass `identifier_field="email"` (or another model column) to use a different login identifier. That field name is also used in the `/auth/token` JSON body. Pass the actual `LENAPI` instance with `app=app` when you want refresh-token replay protection to use its shared security store; do not pass a `Router` there.
1041
+
1042
+ For example, a custom model with `email` and a hashed `password` can sign in using `{"email": "ada@example.com", "password": "..."}`:
1043
+
1044
+ ```python
1045
+ from lenapi_bootstrap import create_app
1046
+ from lenapi import Depends, authentication
1047
+
1048
+ app = create_app(__file__, __package__)
1049
+ from app.models import Customer
1050
+ auth = authentication(
1051
+ "jwt",
1052
+ user_model=Customer,
1053
+ identifier_field="email",
1054
+ app=app,
1055
+ )
1056
+
1057
+ app.include_router(auth.router()) # POST /auth/token and POST /auth/refresh
1058
+
1059
+ @app.get("/profile", auth=auth)
1060
+ def profile(identity=Depends(auth)):
1061
+ return {"customer_id": identity["sub"]}
1062
+ ```
1063
+
1064
+ `Customer` must define an `email` column and a `password` column containing a LenAPI-compatible password hash. Your registration or provisioning flow is responsible for collecting the email and storing the password hash. `JWTAuth` signs and verifies HS256 tokens, checks expiry and configured issuer, and rejects refresh tokens when used as access tokens. Keep the signing key secret and serve credentials only over HTTPS.
1041
1065
 
1042
1066
  ## API-key authentication
1043
1067
 
@@ -1851,7 +1875,7 @@ student = Student.create(
1851
1875
 
1852
1876
  `DateField`, `TimeField`, and `DateTimeField` default to the current UTC date or time when no value or explicit default is supplied. Pass `auto_add_now=False` to disable this behavior, or provide your own `default`. `DurationField(weeks=1)` stores a fixed seven-day `timedelta`; add it to a timestamp when calculating an expiry date. Months and years are calendar intervals and should use calendar-aware date arithmetic.
1853
1877
 
1854
- `PositiveIntegerField` and `PositiveBigIntegerField` reject negative values in LenAPI and add a named database `CHECK` constraint, so direct SQL writes are protected too. `EmailField`, `PhoneField`, `IPField`, `URLField`, and `UUIDField` validate values when a model instance is saved. `PhoneField` accepts a leading `+` followed by country code and digits, up to 18 characters total. `IPField` accepts IPv4 and IPv6. `URLField` accepts `http://`, `https://`, and `www.` URLs. Customize messages with `error_messages`, for example `email = model.EmailField(error_messages={"email": "This is not an email"})`; `unique` is also a supported message key for a unique field.
1878
+ `PositiveIntegerField` and `PositiveBigIntegerField` reject negative values in LenAPI and add a named database `CHECK` constraint, so direct SQL writes are protected too. `EmailField`, `PhoneField`, `IPField`, `URLField`, and `UUIDField` validate values when a model instance is saved. `PhoneField` accepts either a `+` country-code number or a local number beginning with `0`, up to 18 characters. Declare phone numbers as strings and send them quoted in JSON so leading zeroes are preserved; bare JSON numbers such as `08099448550` are invalid JSON. `IPField` accepts IPv4 and IPv6. `URLField` accepts `http://`, `https://`, and `www.` URLs. Customize messages with `error_messages`, for example `email = model.EmailField(error_messages={"email": "This is not an email"})`; `unique` is also a supported message key for a unique field.
1855
1879
 
1856
1880
  The base model supplies an auto-incrementing integer `id`. A model may declare its own primary key instead; LenAPI will not add a second `id` key:
1857
1881
 
@@ -2030,7 +2054,14 @@ async_total = async_page.total_count
2030
2054
  async_pages = async_page.page_count
2031
2055
  ```
2032
2056
 
2033
- `Student.get.all()` returns all rows synchronously; its async counterpart is `await Student.aget.all()`. For filtered async lists, use `await Student.afilter(...).all()`. `Student.get(...)` builds a single-row lookup; call `.get()` for the model instance or `.exists()` for a boolean. The async single-row counterpart is `await Student.aget(...)`, and its existence check is `await Student.aget(...).exists()`. Multiple matches are an error rather than an arbitrary row selection. `Student.get("field", ...).get(...)` selects one or more columns; one selected column returns a scalar and multiple columns return a mapping. This is a query projection, not a uniqueness constraint. To make the database reject duplicates, declare `unique=True` on the field.
2057
+ `Student.get.all()` returns all rows synchronously; its async counterpart is `await Student.aget.all()`. For filtered lists, use `Student.filter(...).all()` or `await Student.afilter(...).all()`. Both sync and async filtered querysets support existence checks:
2058
+
2059
+ ```python
2060
+ has_python_students = Student.filter(course="Python").exists()
2061
+ has_python_students_async = await Student.afilter(course="Python").exists()
2062
+ ```
2063
+
2064
+ `Student.get(...)` builds a single-row lookup; call `.get()` for the model instance or `.exists()` for a boolean. The async single-row counterpart is `await Student.aget(...)`, and its existence check is `await Student.aget(...).exists()`. Multiple matches are an error rather than an arbitrary row selection. `Student.get("field", ...).get(...)` selects one or more columns; one selected column returns a scalar and multiple columns return a mapping. This is a query projection, not a uniqueness constraint. To make the database reject duplicates, declare `unique=True` on the field.
2034
2065
 
2035
2066
  ### Fetch related records
2036
2067
 
@@ -8,7 +8,7 @@ if sys.version_info < (3, 11):
8
8
  "on 2026-10-01 and is no longer supported."
9
9
  )
10
10
 
11
- __version__ = "0.1.3"
11
+ __version__ = "0.1.4.1"
12
12
 
13
13
  from .application import Application, LENAPI, Router
14
14
  from .background import BackgroundQueue, BackgroundTasks
@@ -681,7 +681,8 @@ class Application(Router):
681
681
  hint = (
682
682
  "Run `python lenora.py migration` to create or update the security store table."
683
683
  if self.security_storage == "database"
684
- else "Install `lenapi[redis]` and check REDIS_URL and Redis availability."
684
+ else "Install the Redis extra in the environment running LenAPI with "
685
+ "`python -m pip install 'lenapi[redis]'`, then check REDIS_URL and Redis availability."
685
686
  )
686
687
  response = JSONResponse(
687
688
  {"detail": f"Security storage is unavailable. {hint}"}, status_code=503
@@ -116,6 +116,7 @@ def jwt_auth(settings, user_model=None, *, identifier_field="username", password
116
116
  refresh_token_lifetime_seconds=token.get("REFRESH_LIFETIME_SECONDS", 60 * 60 * 24 * 30),
117
117
  issuer=token.get("ISSUER"),
118
118
  security_store=security_store,
119
+ login_field=identifier_field,
119
120
  authenticate=(
120
121
  _user_authenticator(user_model, identifier_field, password_field)
121
122
  if user_model is not None else None
@@ -149,20 +150,38 @@ def webhook(secret, *, tolerance_seconds=300, nonce_store=None):
149
150
  return WebhookAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
150
151
 
151
152
 
152
- def configure(settings, user_model=None, *, security_store=None, api_key_field=None):
153
+ def configure(
154
+ settings,
155
+ user_model=None,
156
+ *,
157
+ security_store=None,
158
+ api_key_field=None,
159
+ identifier_field=None,
160
+ password_field="password",
161
+ ):
153
162
  """Build the primary authenticator selected by ``settings.AUTHENTICATION``."""
154
163
  if user_model is None:
155
164
  from .models import User
156
165
 
157
166
  user_model = User
158
167
  mode = getattr(settings, "AUTHENTICATION", "jwt").lower()
159
- identifier_field = getattr(settings, "BASIC_AUTH_FIELD", "username")
168
+ identifier_field = identifier_field or getattr(settings, "BASIC_AUTH_FIELD", "username")
160
169
  if mode == "basic_login":
161
- return basic(user_model, identifier_field=identifier_field)
170
+ return basic(
171
+ user_model,
172
+ identifier_field=identifier_field,
173
+ password_field=password_field,
174
+ )
162
175
  if mode == "api_key":
163
176
  return api_key(user_model, field=api_key_field or "api_key")
164
177
  if mode == "jwt":
165
- return jwt_auth(settings, user_model, security_store=security_store)
178
+ return jwt_auth(
179
+ settings,
180
+ user_model,
181
+ identifier_field=identifier_field,
182
+ password_field=password_field,
183
+ security_store=security_store,
184
+ )
166
185
  if mode == "session":
167
186
  options = getattr(settings, "SESSION", {})
168
187
  return session(store=security_store, cookie_name=options.get("COOKIE_NAME", "lenapi_session"))
@@ -181,7 +200,16 @@ def configure(settings, user_model=None, *, security_store=None, api_key_field=N
181
200
  raise ValueError(f"Unknown AUTHENTICATION mode: {mode}")
182
201
 
183
202
 
184
- def authentication_type(auth_type, *, settings, user_model=None, api_key_field=None, app=None):
203
+ def authentication_type(
204
+ auth_type,
205
+ *,
206
+ settings,
207
+ user_model=None,
208
+ api_key_field=None,
209
+ identifier_field=None,
210
+ password_field="password",
211
+ app=None,
212
+ ):
185
213
  """Create the authenticator selected by a project settings module.
186
214
 
187
215
  ``auth_type`` may be ``"jwt"``, ``"api_key"``, ``"basic_login``,
@@ -208,16 +236,29 @@ def authentication_type(auth_type, *, settings, user_model=None, api_key_field=N
208
236
  user_model,
209
237
  security_store=getattr(app, "security_store", None),
210
238
  api_key_field=api_key_field,
239
+ identifier_field=identifier_field,
240
+ password_field=password_field,
211
241
  )
212
242
  return authenticator
213
243
 
214
244
 
215
- def authentication(type="jwt", *, settings=None, user_model=None, api_key_field=None, app=None):
245
+ def authentication(
246
+ type="jwt",
247
+ *,
248
+ settings=None,
249
+ user_model=None,
250
+ api_key_field=None,
251
+ identifier_field=None,
252
+ password_field="password",
253
+ app=None,
254
+ ):
216
255
  """Create a route authentication dependency from project settings.
217
256
 
218
257
  By default, ``settings`` is imported from the current project and the
219
- framework's built-in ``User`` model is used. Pass explicit values when
220
- configuring authentication outside a generated project.
258
+ framework's built-in ``User`` model is used. ``identifier_field`` selects
259
+ the database field used for JWT/Basic login; for JWT it also names the
260
+ identifier property in the token endpoint's JSON body. Pass the actual
261
+ LENAPI instance as ``app`` to share its security store.
221
262
  """
222
263
  if settings is None:
223
264
  try:
@@ -233,6 +274,8 @@ def authentication(type="jwt", *, settings=None, user_model=None, api_key_field=
233
274
  settings=settings,
234
275
  user_model=user_model,
235
276
  api_key_field=api_key_field,
277
+ identifier_field=identifier_field,
278
+ password_field=password_field,
236
279
  app=app,
237
280
  )
238
281
 
@@ -372,6 +372,10 @@ class _QuerySet:
372
372
  row = result.mappings().first()
373
373
  return None if row is None else dict(row)
374
374
 
375
+ def exists(self) -> bool:
376
+ """Return whether this query matches at least one row."""
377
+ return self.first() is not None
378
+
375
379
  def one_or_none(self):
376
380
  """Return the sole matching row, or raise if the lookup is ambiguous."""
377
381
  result = _session().execute(self._statement())
@@ -1234,7 +1238,12 @@ def IPField(max_length: int = 45, **kwargs):
1234
1238
 
1235
1239
 
1236
1240
  def PhoneField(max_length: int = 18, **kwargs):
1237
- """Declare a string column intended for an international phone number."""
1241
+ """Declare a string phone column accepting international or local numbers.
1242
+
1243
+ International values begin with ``+`` and a country code. Local values may
1244
+ begin with ``0`` (for example, ``"08099448550"``). Store phone numbers as
1245
+ strings so leading zeroes are preserved.
1246
+ """
1238
1247
  info = dict(kwargs.pop("info", {}) or {})
1239
1248
  info["lenapi_validation"] = "phone"
1240
1249
  return _field(String(max_length), info=info, **kwargs)
@@ -1337,8 +1346,14 @@ def _validate_instance(instance):
1337
1346
  valid = isinstance(value, str) and re.fullmatch(r"[^\s@]+@[^\s@]+", value) is not None
1338
1347
  message = _validation_error(column, rule, "Enter a valid email address")
1339
1348
  elif rule == "phone":
1340
- valid = isinstance(value, str) and re.fullmatch(r"\+[1-9][0-9]{0,16}", value) is not None
1341
- message = _validation_error(column, rule, "Phone must start with + and contain at most 18 digits")
1349
+ valid = isinstance(value, str) and re.fullmatch(
1350
+ r"(?:\+[1-9][0-9]{0,16}|0[0-9]{7,17})", value
1351
+ ) is not None
1352
+ message = _validation_error(
1353
+ column,
1354
+ rule,
1355
+ "Enter a phone number with +country code or a local number starting with 0 (up to 18 digits)",
1356
+ )
1342
1357
  elif rule == "ip":
1343
1358
  try:
1344
1359
  ipaddress.ip_address(value)
@@ -332,7 +332,12 @@ class Request:
332
332
  return self._body
333
333
 
334
334
  async def json(self):
335
- return json.loads((await self.body()).decode("utf-8"))
335
+ """Parse a JSON request body, returning HTTP 400 for malformed JSON."""
336
+ try:
337
+ text = (await self.body()).decode("utf-8")
338
+ return json.loads(text)
339
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
340
+ raise HTTPException(400, "Malformed JSON request body") from exc
336
341
 
337
342
  async def form(self) -> dict[str, str | UploadFile | list[str | UploadFile]]:
338
343
  """Parse URL-encoded or multipart form data, including file uploads."""
@@ -12,7 +12,7 @@ import threading
12
12
  import time
13
13
  from typing import Any
14
14
 
15
- from pydantic import BaseModel, Field
15
+ from pydantic import BaseModel, Field, create_model
16
16
 
17
17
  from .concurrency import call_sync_or_async
18
18
  from .request import Request
@@ -117,6 +117,7 @@ class JWTAuth:
117
117
  issuer: str | None = None,
118
118
  authenticate=None,
119
119
  security_store=None,
120
+ login_field: str = "username",
120
121
  ) -> None:
121
122
  key = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
122
123
  if not isinstance(key, bytes) or len(key) < 32:
@@ -125,12 +126,15 @@ class JWTAuth:
125
126
  raise ValueError("token_lifetime_seconds must be positive")
126
127
  if refresh_token_lifetime_seconds <= 0:
127
128
  raise ValueError("refresh_token_lifetime_seconds must be positive")
129
+ if not isinstance(login_field, str) or not login_field.isidentifier() or login_field == "password":
130
+ raise ValueError("login_field must be a valid model field name other than 'password'")
128
131
  self.secret_key = key
129
132
  self.token_lifetime_seconds = token_lifetime_seconds
130
133
  self.refresh_token_lifetime_seconds = refresh_token_lifetime_seconds
131
134
  self.issuer = issuer
132
135
  self.authenticate = authenticate
133
136
  self.security_store = security_store
137
+ self.login_field = login_field
134
138
  self._refresh_namespace = hashlib.sha256(key).hexdigest()[:32]
135
139
  self._used_refresh_tokens: dict[str, float] = {}
136
140
  self._refresh_lock = threading.Lock()
@@ -229,15 +233,16 @@ class JWTAuth:
229
233
  security_store=None):
230
234
  """Build `/auth/token` and `/auth/refresh` routes for an app.
231
235
 
232
- `authenticate(username, password)` may be supplied here or when
233
- constructing the instance. It must return the user's string ID, or
236
+ `authenticate(identifier, password)` may be supplied here or when
237
+ constructing the instance. The identifier field name is controlled by
238
+ `login_field`. The callback must return the user's string ID, or
234
239
  `(string_id, claims)`, when credentials are valid; otherwise return None.
235
240
  """
236
241
  from .application import Router
237
242
 
238
243
  authenticate = authenticate or self.authenticate
239
244
  if authenticate is None:
240
- raise ValueError("JWT token routes require an authenticate(username, password) callback")
245
+ raise ValueError("JWT token routes require an authenticate(identifier, password) callback")
241
246
 
242
247
  # Keep password verification endpoints rate-limited even when the app
243
248
  # has no global throttle configured. An empty mapping explicitly opts
@@ -245,14 +250,21 @@ class JWTAuth:
245
250
  token_throttle = {"ip": "10/minute"} if throttle is None else throttle
246
251
  refresh_store = security_store if security_store is not None else self.security_store
247
252
  router = Router(prefix="/auth")
253
+ credentials_model = create_model(
254
+ f"JWTTokenCredentials_{self.login_field}",
255
+ **{
256
+ self.login_field: (str, Field(min_length=1)),
257
+ "password": (str, Field(min_length=1)),
258
+ },
259
+ )
248
260
 
249
261
  @router.post("/token", include_in_schema=False, throttle=token_throttle)
250
- async def issue_token(credentials: _Credentials):
262
+ async def issue_token(credentials: credentials_model):
251
263
  user = await call_sync_or_async(
252
- authenticate, credentials.username, credentials.password
264
+ authenticate, getattr(credentials, self.login_field), credentials.password
253
265
  )
254
266
  if user is None or user is False:
255
- raise HTTPException(401, "Invalid username or password")
267
+ raise HTTPException(401, "Invalid login credentials")
256
268
  if isinstance(user, tuple) and len(user) == 2:
257
269
  subject, claims = user
258
270
  else:
@@ -188,7 +188,11 @@ class RedisSecurityStore:
188
188
  try:
189
189
  from redis.asyncio import Redis
190
190
  except ImportError as exc:
191
- raise ValueError("Redis security storage requires `pip install lenapi[redis]`") from exc
191
+ raise ValueError(
192
+ "Redis security storage requires the Redis extra. Install it in the same "
193
+ "environment that runs LenAPI with: `python -m pip install 'lenapi[redis]'`. "
194
+ "Keep the quotes around the extra in zsh."
195
+ ) from exc
192
196
  self.client = Redis.from_url(url, decode_responses=True)
193
197
 
194
198
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lenapi
3
- Version: 0.1.3
3
+ Version: 0.1.4.1
4
4
  Summary: A modern ASGI framework with Pydantic validation, sync and async database access, WebSockets, authentication, and integrated API documentation.
5
5
  Author: Maekandex Communication Limitation
6
6
  Author-email: "Ifeanyi M. Onovo" <ifeanyi@maekandex.com.ng>
@@ -425,7 +425,7 @@ app.include_router(api)
425
425
 
426
426
  Rate strings accept seconds, minutes, hours, or days, for example `"10/second"`, `"60/minute"`, and `"1000/day"`. Throttling uses a fixed window that starts with the first request. `ip` counts every request from the client address before authentication. `anonymous` counts public calls and, on routes using `auth=`, all requests from that IP before credentials are verified. This intentionally includes successful logins so repeated password checks are stopped before they consume CPU. `authenticated` counts successful route-level authentication by a stable identity returned from `auth=`. Routes using `BasicAuth` receive a default `ip` limit of 10 requests per minute if no pre-auth `ip` or `anonymous` limit is configured. Set `throttle={}` to deliberately disable the inherited and default limit. Route and router `throttle=` values otherwise override inherited limits; an empty mapping disables inherited limits for that route or router. Endpoint-only auth declared with `Depends(...)` does not supply the identity to the authenticated quota; attach it with `auth=` when you want identity-based quotas.
427
427
 
428
- When a quota is exceeded, LenAPI returns `429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Quota counters use the same security backend as IP cooldowns. Choose `SECURITY_STORAGE = "database"` or `"redis"` in project `settings.py`. Database mode stores state in the internal `lenapi_security_store` table or MongoDB collection, initialized by migrations. Redis mode uses `REDIS_URL` and requires `pip install 'lenapi[redis]'` plus a running Redis server. The generated settings default to database storage and include `REDIS_URL = "redis://127.0.0.1:6379/0"` for switching to Redis. Shared database or Redis storage coordinates limits across workers. `SECURITY_STORAGE = "memory"` keeps counters in each process, so use it only for single-process development. LenAPI uses the ASGI connection's client address and does not trust forwarded-IP headers automatically; configure trusted proxy handling at your deployment edge before relying on IP quotas behind a proxy.
428
+ When a quota is exceeded, LenAPI returns `429 Too Many Requests` with `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Quota counters use the same security backend as IP cooldowns. Choose `SECURITY_STORAGE = "database"` or `"redis"` in project `settings.py`. Database mode stores state in the internal `lenapi_security_store` table or MongoDB collection, initialized by migrations. Redis mode uses `REDIS_URL` and requires `python -m pip install 'lenapi[redis]'` plus a running Redis server. Run the install command with the same Python environment that starts LenAPI; keep the quotes around the extra in zsh. The generated settings default to database storage and include `REDIS_URL = "redis://127.0.0.1:6379/0"` for switching to Redis. Shared database or Redis storage coordinates limits across workers. `SECURITY_STORAGE = "memory"` keeps counters in each process, so use it only for single-process development. LenAPI uses the ASGI connection's client address and does not trust forwarded-IP headers automatically; configure trusted proxy handling at your deployment edge before relying on IP quotas behind a proxy.
429
429
 
430
430
  For example, app-level limits are inherited by routes that do not set their own throttle. A route-specific mapping replaces the inherited mapping, so repeat any global scopes that you still want on that route:
431
431
 
@@ -1089,7 +1089,31 @@ app.include_router(public)
1089
1089
 
1090
1090
  The framework-backed JWT authenticator checks the submitted username and password against the database `User` model and its stored password hash. `POST /auth/token` accepts `{"username": "...", "password": "..."}` and returns an access token plus a refresh token. Send the access token as `Authorization: Bearer <access_token>`. `POST /auth/refresh` accepts `{"refresh_token": "..."}` and returns replacement tokens. The token and refresh routes are intentionally hidden from Swagger; protected API routes still show their Bearer scheme and Swagger's **Authorize** control. These routes have a default IP limit of 10 requests per minute, including failed credential checks; pass `throttle={"ip": "5/minute"}` to `jwt.router(...)` for a different limit. In production, use shared `SECURITY_STORAGE = "database"` or `"redis"` for refresh replay protection across workers. Refresh tokens are single-use; the in-memory fallback coordinates only within one process.
1091
1091
 
1092
- For a custom user table, create the JWT dependency with `authentication("jwt", user_model=YourUser)`. Basic and JWT can use your model as long as it has the selected identifier column and a `password` column containing LenAPI-compatible password hashes. For Basic, select the identifier with `BASIC_AUTH_FIELD`. Or configure `JWTAuth` with an `authenticate(username, password)` callback. That callback must verify a password hash and return the user's string ID, `(string_id, claims)`, or `None`. `JWTAuth` signs and verifies HS256 tokens, checks expiry and configured issuer, and rejects refresh tokens when used as access tokens. Keep the signing key secret and serve credentials only over HTTPS.
1092
+ For a custom user table, create the JWT dependency with `authentication("jwt", user_model=YourUser)`. Basic and JWT can use your model as long as it has the selected identifier column and a `password` column containing LenAPI-compatible password hashes. For Basic, select the identifier with `BASIC_AUTH_FIELD`. JWT defaults to the `username` column; pass `identifier_field="email"` (or another model column) to use a different login identifier. That field name is also used in the `/auth/token` JSON body. Pass the actual `LENAPI` instance with `app=app` when you want refresh-token replay protection to use its shared security store; do not pass a `Router` there.
1093
+
1094
+ For example, a custom model with `email` and a hashed `password` can sign in using `{"email": "ada@example.com", "password": "..."}`:
1095
+
1096
+ ```python
1097
+ from lenapi_bootstrap import create_app
1098
+ from lenapi import Depends, authentication
1099
+
1100
+ app = create_app(__file__, __package__)
1101
+ from app.models import Customer
1102
+ auth = authentication(
1103
+ "jwt",
1104
+ user_model=Customer,
1105
+ identifier_field="email",
1106
+ app=app,
1107
+ )
1108
+
1109
+ app.include_router(auth.router()) # POST /auth/token and POST /auth/refresh
1110
+
1111
+ @app.get("/profile", auth=auth)
1112
+ def profile(identity=Depends(auth)):
1113
+ return {"customer_id": identity["sub"]}
1114
+ ```
1115
+
1116
+ `Customer` must define an `email` column and a `password` column containing a LenAPI-compatible password hash. Your registration or provisioning flow is responsible for collecting the email and storing the password hash. `JWTAuth` signs and verifies HS256 tokens, checks expiry and configured issuer, and rejects refresh tokens when used as access tokens. Keep the signing key secret and serve credentials only over HTTPS.
1093
1117
 
1094
1118
  ## API-key authentication
1095
1119
 
@@ -1903,7 +1927,7 @@ student = Student.create(
1903
1927
 
1904
1928
  `DateField`, `TimeField`, and `DateTimeField` default to the current UTC date or time when no value or explicit default is supplied. Pass `auto_add_now=False` to disable this behavior, or provide your own `default`. `DurationField(weeks=1)` stores a fixed seven-day `timedelta`; add it to a timestamp when calculating an expiry date. Months and years are calendar intervals and should use calendar-aware date arithmetic.
1905
1929
 
1906
- `PositiveIntegerField` and `PositiveBigIntegerField` reject negative values in LenAPI and add a named database `CHECK` constraint, so direct SQL writes are protected too. `EmailField`, `PhoneField`, `IPField`, `URLField`, and `UUIDField` validate values when a model instance is saved. `PhoneField` accepts a leading `+` followed by country code and digits, up to 18 characters total. `IPField` accepts IPv4 and IPv6. `URLField` accepts `http://`, `https://`, and `www.` URLs. Customize messages with `error_messages`, for example `email = model.EmailField(error_messages={"email": "This is not an email"})`; `unique` is also a supported message key for a unique field.
1930
+ `PositiveIntegerField` and `PositiveBigIntegerField` reject negative values in LenAPI and add a named database `CHECK` constraint, so direct SQL writes are protected too. `EmailField`, `PhoneField`, `IPField`, `URLField`, and `UUIDField` validate values when a model instance is saved. `PhoneField` accepts either a `+` country-code number or a local number beginning with `0`, up to 18 characters. Declare phone numbers as strings and send them quoted in JSON so leading zeroes are preserved; bare JSON numbers such as `08099448550` are invalid JSON. `IPField` accepts IPv4 and IPv6. `URLField` accepts `http://`, `https://`, and `www.` URLs. Customize messages with `error_messages`, for example `email = model.EmailField(error_messages={"email": "This is not an email"})`; `unique` is also a supported message key for a unique field.
1907
1931
 
1908
1932
  The base model supplies an auto-incrementing integer `id`. A model may declare its own primary key instead; LenAPI will not add a second `id` key:
1909
1933
 
@@ -2082,7 +2106,14 @@ async_total = async_page.total_count
2082
2106
  async_pages = async_page.page_count
2083
2107
  ```
2084
2108
 
2085
- `Student.get.all()` returns all rows synchronously; its async counterpart is `await Student.aget.all()`. For filtered async lists, use `await Student.afilter(...).all()`. `Student.get(...)` builds a single-row lookup; call `.get()` for the model instance or `.exists()` for a boolean. The async single-row counterpart is `await Student.aget(...)`, and its existence check is `await Student.aget(...).exists()`. Multiple matches are an error rather than an arbitrary row selection. `Student.get("field", ...).get(...)` selects one or more columns; one selected column returns a scalar and multiple columns return a mapping. This is a query projection, not a uniqueness constraint. To make the database reject duplicates, declare `unique=True` on the field.
2109
+ `Student.get.all()` returns all rows synchronously; its async counterpart is `await Student.aget.all()`. For filtered lists, use `Student.filter(...).all()` or `await Student.afilter(...).all()`. Both sync and async filtered querysets support existence checks:
2110
+
2111
+ ```python
2112
+ has_python_students = Student.filter(course="Python").exists()
2113
+ has_python_students_async = await Student.afilter(course="Python").exists()
2114
+ ```
2115
+
2116
+ `Student.get(...)` builds a single-row lookup; call `.get()` for the model instance or `.exists()` for a boolean. The async single-row counterpart is `await Student.aget(...)`, and its existence check is `await Student.aget(...).exists()`. Multiple matches are an error rather than an arbitrary row selection. `Student.get("field", ...).get(...)` selects one or more columns; one selected column returns a scalar and multiple columns return a mapping. This is a query projection, not a uniqueness constraint. To make the database reject duplicates, declare `unique=True` on the field.
2086
2117
 
2087
2118
  ### Fetch related records
2088
2119
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lenapi"
7
- version = "0.1.3"
7
+ version = "0.1.4.1"
8
8
  description = "A modern ASGI framework with Pydantic validation, sync and async database access, WebSockets, authentication, and integrated API documentation."
9
9
  authors = [
10
10
  {name = "Ifeanyi M. Onovo", email = "ifeanyi@maekandex.com.ng"},
@@ -143,6 +143,38 @@ def test_lenapi_is_the_only_public_application_class():
143
143
  app.database.close()
144
144
 
145
145
 
146
+ def test_malformed_json_returns_400_instead_of_server_error():
147
+ app = make_app()
148
+
149
+ class Payload(BaseModel):
150
+ phone_number: str
151
+
152
+ @app.post("/payload")
153
+ def payload(data: Payload):
154
+ return {"phone_number": data.phone_number}
155
+
156
+ valid_status, _, valid_body = asgi_request(
157
+ app,
158
+ "/payload",
159
+ method="POST",
160
+ body=b'{"phone_number": "08099448550"}',
161
+ headers=[(b"content-type", b"application/json")],
162
+ )
163
+ assert valid_status == 200
164
+ assert json.loads(valid_body) == {"phone_number": "08099448550"}
165
+
166
+ status, _, body = asgi_request(
167
+ app,
168
+ "/payload",
169
+ method="POST",
170
+ body=b'{"phone_number": 08099448550}',
171
+ headers=[(b"content-type", b"application/json")],
172
+ )
173
+ assert status == 400
174
+ assert json.loads(body) == {"detail": "Malformed JSON request body"}
175
+ app.database.close()
176
+
177
+
146
178
  def test_multiple_routers_validation_response_models_and_openapi_visibility():
147
179
  class Input(BaseModel):
148
180
  name: str
@@ -690,6 +722,8 @@ def test_model_fields_defaults_uuid_unique_indexes_and_all_crud_helpers(tmp_path
690
722
  assert FeatureRecord.get(name="Ada").get().info == {"active": True}
691
723
  assert FeatureRecord.get_or_404(name="Grace").amount == Decimal("25.00")
692
724
  assert FeatureRecord.get(name="missing").exists() is False
725
+ assert FeatureRecord.filter(group="math").exists() is True
726
+ assert FeatureRecord.filter(group="missing").exists() is False
693
727
  assert FeatureRecord.get_or_create(name="Ada")[1] is False
694
728
  assert FeatureRecord.get_or_create({"group": "logic"}, name="Turing")[0].group == "logic"
695
729
  assert FeatureRecord.create_update({"amount": Decimal("13")}, name="Ada").amount == Decimal("13")
@@ -836,7 +870,7 @@ def test_new_fields_validate_values_and_default_temporal_fields(tmp_path):
836
870
  db = Database(f"sqlite:///{tmp_path / 'new-model-fields.db'}")
837
871
  tables = [
838
872
  PositiveFieldRecord.__table__, TimestampDurationRecord.__table__,
839
- UniqueMessageRecord.__table__,
873
+ UniqueMessageRecord.__table__, ValidatedFieldRecord.__table__,
840
874
  ]
841
875
  Model.metadata.create_all(db.engine, tables=tables)
842
876
  session = db.session()
@@ -854,6 +888,12 @@ def test_new_fields_validate_values_and_default_temporal_fields(tmp_path):
854
888
  assert isinstance(record.created_at, datetime)
855
889
  assert record.trial_period == timedelta(days=7)
856
890
 
891
+ local_phone = ValidatedFieldRecord.create(
892
+ email="local@example.com", phone="08099448550", address="127.0.0.1",
893
+ website="https://example.com", public_id=uuid.uuid4(),
894
+ )
895
+ assert local_phone.phone == "08099448550"
896
+
857
897
  UniqueMessageRecord.create(email="ada@example.com")
858
898
  with pytest.raises(HTTPException) as duplicate:
859
899
  UniqueMessageRecord.create(email="ada@example.com")
@@ -1016,6 +1056,46 @@ def test_authentication_factory_modes_and_database_user_password_helpers():
1016
1056
  assert callable(register_user)
1017
1057
 
1018
1058
 
1059
+ def test_jwt_custom_identifier_uses_email_for_lookup_and_token_request(monkeypatch):
1060
+ class EmailAuthCustomer(Model):
1061
+ email = StringField(max_length=254, unique=True)
1062
+ password = StringField(max_length=255)
1063
+
1064
+ from lenapi import authentication
1065
+
1066
+ settings = SimpleNamespace(
1067
+ AUTHENTICATION="jwt",
1068
+ TOKEN={"SECRET_KEY": "e" * 32},
1069
+ )
1070
+ auth = authentication(
1071
+ "jwt",
1072
+ settings=settings,
1073
+ user_model=EmailAuthCustomer,
1074
+ identifier_field="email",
1075
+ )
1076
+ lookups = []
1077
+
1078
+ class QueryResult:
1079
+ def get(self):
1080
+ return SimpleNamespace(id=17, password="encoded-password")
1081
+
1082
+ def fake_get(cls, **filters):
1083
+ lookups.append(filters)
1084
+ return QueryResult()
1085
+
1086
+ auth_module = importlib.import_module("lenapi.authentication")
1087
+ monkeypatch.setattr(EmailAuthCustomer, "get", classmethod(fake_get))
1088
+ monkeypatch.setattr(auth_module, "check_password", lambda raw, encoded: encoded == "encoded-password")
1089
+
1090
+ assert auth.authenticate("ada@example.com", "secret") == "17"
1091
+ assert lookups == [{"email": "ada@example.com"}]
1092
+
1093
+ token_router = auth.router()
1094
+ token_route = next(route for route in token_router.routes if route.path == "/auth/token")
1095
+ credentials_model = token_route.endpoint.__annotations__["credentials"]
1096
+ assert set(credentials_model.model_fields) == {"email", "password"}
1097
+
1098
+
1019
1099
  def test_custom_model_api_key_field_is_required_validated_and_used(monkeypatch):
1020
1100
  class CustomerForAPIKey(Model):
1021
1101
  username = StringField(max_length=64, unique=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes