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.
- {lenapi-0.1.3 → lenapi-0.1.4.1}/PKG-INFO +36 -5
- {lenapi-0.1.3 → lenapi-0.1.4.1}/README.md +35 -4
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/__init__.py +1 -1
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/application.py +2 -1
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/authentication.py +51 -8
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/models.py +18 -3
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/request.py +6 -1
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/security.py +19 -7
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/security_store.py +5 -1
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/PKG-INFO +36 -5
- {lenapi-0.1.3 → lenapi-0.1.4.1}/pyproject.toml +1 -1
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_framework_features.py +81 -1
- {lenapi-0.1.3 → lenapi-0.1.4.1}/LICENSE +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/admin.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/auth.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/authorization.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/background.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/cli.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/concurrency.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/database.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/dependencies.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/docs_guard.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/logging.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/middleware.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/model.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/mongodb.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/openapi.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/parameters.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/project.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/response.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/sentinels.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/service.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/throttling.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi/transaction.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/SOURCES.txt +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/dependency_links.txt +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/entry_points.txt +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/requires.txt +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi.egg-info/top_level.txt +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/lenapi_bootstrap.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/setup.cfg +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_admin_console.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_cli_settings_recovery.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_docs_guard.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_media_upload.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_mongodb.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_platform_services.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_project_isolation.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_security_patch.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_sentinels.py +0 -0
- {lenapi-0.1.3 → lenapi-0.1.4.1}/tests/test_throttle.py +0 -0
- {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
|
+
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`.
|
|
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
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
220
|
-
|
|
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
|
|
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(
|
|
1341
|
-
|
|
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
|
-
|
|
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(
|
|
233
|
-
constructing the instance.
|
|
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(
|
|
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:
|
|
262
|
+
async def issue_token(credentials: credentials_model):
|
|
251
263
|
user = await call_sync_or_async(
|
|
252
|
-
authenticate, credentials.
|
|
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
|
|
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(
|
|
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
|
+
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`.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|