lenapi 0.1.0__tar.gz → 0.1.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 (35) hide show
  1. {lenapi-0.1.0 → lenapi-0.1.1}/PKG-INFO +11 -10
  2. {lenapi-0.1.0 → lenapi-0.1.1}/README.md +10 -9
  3. lenapi-0.1.1/lenapi/authentication.py +218 -0
  4. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/cli.py +70 -92
  5. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/security.py +9 -2
  6. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/PKG-INFO +11 -10
  7. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/SOURCES.txt +1 -0
  8. {lenapi-0.1.0 → lenapi-0.1.1}/pyproject.toml +1 -1
  9. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/__init__.py +0 -0
  10. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/application.py +0 -0
  11. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/authorization.py +0 -0
  12. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/background.py +0 -0
  13. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/database.py +0 -0
  14. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/dependencies.py +0 -0
  15. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/logging.py +0 -0
  16. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/middleware.py +0 -0
  17. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/model.py +0 -0
  18. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/models.py +0 -0
  19. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/mongodb.py +0 -0
  20. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/openapi.py +0 -0
  21. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/parameters.py +0 -0
  22. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/payments.py +0 -0
  23. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/request.py +0 -0
  24. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/response.py +0 -0
  25. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/security_store.py +0 -0
  26. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi/service.py +0 -0
  27. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/dependency_links.txt +0 -0
  28. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/entry_points.txt +0 -0
  29. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/requires.txt +0 -0
  30. {lenapi-0.1.0 → lenapi-0.1.1}/lenapi.egg-info/top_level.txt +0 -0
  31. {lenapi-0.1.0 → lenapi-0.1.1}/setup.cfg +0 -0
  32. {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_media_upload.py +0 -0
  33. {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_mongodb.py +0 -0
  34. {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_platform_services.py +0 -0
  35. {lenapi-0.1.0 → lenapi-0.1.1}/tests/test_throttle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lenapi
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A small, typed ASGI web framework
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -214,13 +214,13 @@ The repository root contains the lenApi library. Create an application scaffold
214
214
 
215
215
  ```bash
216
216
  python -m pip install -e '.[server]'
217
- python lenora.py startproject my_api
217
+ lenora startproject my_api
218
218
  cd my_api
219
219
  python lenora.py migration
220
220
  python lenora.py runserver
221
221
  ```
222
222
 
223
- The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `settings.py`, `main.py`, `test.py`, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
223
+ Use the installed `lenora` command to create the first project; `python lenora.py ...` commands are available after entering the generated project. The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `app/authentication.py`, `settings.py`, `main.py`, a `tests/` package, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
224
224
 
225
225
  `settings.py` centralizes the API title/version, docs and OpenAPI URLs, host, port, database URL, token settings, and shared security storage. `app = LenApi()` loads these project settings automatically, including the title displayed by Swagger. Each generated project gets a unique high-entropy `SECRET_KEY`; `TOKEN` uses it to sign tokens and configures token lifetime and issuer. Keep the key private. Changing it invalidates tokens signed with the old key. Override the host, port, or SQLite URL with `LENAPI_HOST`, `LENAPI_PORT`, and `LENAPI_DATABASE_URL` environment variables.
226
226
 
@@ -235,7 +235,7 @@ python -m pip install -e '.[test]'
235
235
  python -m pytest
236
236
  ```
237
237
 
238
- The `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `run_test_output` writes a CSV report under that application directory.
238
+ The `test`, `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `test` generates endpoint checks temporarily, runs them, and writes a CSV report under `outputs/`. `run_test_output` also writes a CSV report.
239
239
 
240
240
  For the included Student API example, run those commands from `app/student_api`:
241
241
 
@@ -243,6 +243,7 @@ For the included Student API example, run those commands from `app/student_api`:
243
243
  cd app/student_api
244
244
  python lenora.py generate_test
245
245
  python lenora.py run_test
246
+ python lenora.py test
246
247
  ```
247
248
 
248
249
  Request bodies are limited to 10 MiB by default, and lenApi allows up to 30 seconds to receive the body. Configure `MAX_REQUEST_BODY_SIZE` (bytes) and `REQUEST_BODY_TIMEOUT_SECONDS` in `settings.py`; set either to `None` to disable that limit. Oversized bodies receive HTTP 413, and bodies that exceed the receive timeout receive HTTP 408. Direct `Application(...)` instances accept the equivalent `max_request_body_size` and `request_body_timeout` arguments.
@@ -424,20 +425,20 @@ BASIC_AUTH_FIELD = "email" # username, email, phone, or another User field
424
425
 
425
426
  The HTTP Basic username slot is treated as the configured identifier, so a client can send `auth=(email, password)` or `auth=(phone, password)`. For a custom identifier, add that field to `User` and migrate the database; then set `BASIC_AUTH_FIELD` to the model field name.
426
427
 
427
- The generated `main.py` uses that setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh`. `create_authenticator(settings, ...)` builds the selected dependency; provide callbacks for database-backed credentials and stores:
428
+ The generated `main.py` uses the selected setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh` automatically. Initialize the selected method through `authentication_type`:
428
429
 
429
430
  ```python
430
- from lenapi import Depends, create_authenticator
431
+ from lenapi import Depends
432
+ from lenapi.authentication import authentication_type
433
+ from app import models
431
434
  import settings
432
435
 
433
- auth = create_authenticator(settings, authenticate=authenticate_user)
436
+ auth_type = settings.AUTHENTICATION # "jwt", "api_key", "basic_login", "session", etc.
437
+ auth = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
434
438
 
435
439
  @app.get("/protected")
436
440
  def protected(user=Depends(auth)):
437
441
  return {"authenticated": True}
438
-
439
- if settings.AUTHENTICATION == "jwt":
440
- app.include_router(auth.router(authenticate_user))
441
442
  ```
442
443
 
443
444
  The primary authentication setting chooses one method for the scaffold's default protected route: `jwt`, `api_key`, `basic_login`, `opaque_bearer`, or `session`. Session cookies, HMAC, and OIDC can also be attached as additional callable dependencies on selected routes. This lets an app use API keys generally and require a session cookie on a browser route, or require both on one route:
@@ -180,13 +180,13 @@ The repository root contains the lenApi library. Create an application scaffold
180
180
 
181
181
  ```bash
182
182
  python -m pip install -e '.[server]'
183
- python lenora.py startproject my_api
183
+ lenora startproject my_api
184
184
  cd my_api
185
185
  python lenora.py migration
186
186
  python lenora.py runserver
187
187
  ```
188
188
 
189
- The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `settings.py`, `main.py`, `test.py`, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
189
+ Use the installed `lenora` command to create the first project; `python lenora.py ...` commands are available after entering the generated project. The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `app/authentication.py`, `settings.py`, `main.py`, a `tests/` package, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
190
190
 
191
191
  `settings.py` centralizes the API title/version, docs and OpenAPI URLs, host, port, database URL, token settings, and shared security storage. `app = LenApi()` loads these project settings automatically, including the title displayed by Swagger. Each generated project gets a unique high-entropy `SECRET_KEY`; `TOKEN` uses it to sign tokens and configures token lifetime and issuer. Keep the key private. Changing it invalidates tokens signed with the old key. Override the host, port, or SQLite URL with `LENAPI_HOST`, `LENAPI_PORT`, and `LENAPI_DATABASE_URL` environment variables.
192
192
 
@@ -201,7 +201,7 @@ python -m pip install -e '.[test]'
201
201
  python -m pytest
202
202
  ```
203
203
 
204
- The `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `run_test_output` writes a CSV report under that application directory.
204
+ The `test`, `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `test` generates endpoint checks temporarily, runs them, and writes a CSV report under `outputs/`. `run_test_output` also writes a CSV report.
205
205
 
206
206
  For the included Student API example, run those commands from `app/student_api`:
207
207
 
@@ -209,6 +209,7 @@ For the included Student API example, run those commands from `app/student_api`:
209
209
  cd app/student_api
210
210
  python lenora.py generate_test
211
211
  python lenora.py run_test
212
+ python lenora.py test
212
213
  ```
213
214
 
214
215
  Request bodies are limited to 10 MiB by default, and lenApi allows up to 30 seconds to receive the body. Configure `MAX_REQUEST_BODY_SIZE` (bytes) and `REQUEST_BODY_TIMEOUT_SECONDS` in `settings.py`; set either to `None` to disable that limit. Oversized bodies receive HTTP 413, and bodies that exceed the receive timeout receive HTTP 408. Direct `Application(...)` instances accept the equivalent `max_request_body_size` and `request_body_timeout` arguments.
@@ -390,20 +391,20 @@ BASIC_AUTH_FIELD = "email" # username, email, phone, or another User field
390
391
 
391
392
  The HTTP Basic username slot is treated as the configured identifier, so a client can send `auth=(email, password)` or `auth=(phone, password)`. For a custom identifier, add that field to `User` and migrate the database; then set `BASIC_AUTH_FIELD` to the model field name.
392
393
 
393
- The generated `main.py` uses that setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh`. `create_authenticator(settings, ...)` builds the selected dependency; provide callbacks for database-backed credentials and stores:
394
+ The generated `main.py` uses the selected setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh` automatically. Initialize the selected method through `authentication_type`:
394
395
 
395
396
  ```python
396
- from lenapi import Depends, create_authenticator
397
+ from lenapi import Depends
398
+ from lenapi.authentication import authentication_type
399
+ from app import models
397
400
  import settings
398
401
 
399
- auth = create_authenticator(settings, authenticate=authenticate_user)
402
+ auth_type = settings.AUTHENTICATION # "jwt", "api_key", "basic_login", "session", etc.
403
+ auth = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
400
404
 
401
405
  @app.get("/protected")
402
406
  def protected(user=Depends(auth)):
403
407
  return {"authenticated": True}
404
-
405
- if settings.AUTHENTICATION == "jwt":
406
- app.include_router(auth.router(authenticate_user))
407
408
  ```
408
409
 
409
410
  The primary authentication setting chooses one method for the scaffold's default protected route: `jwt`, `api_key`, `basic_login`, `opaque_bearer`, or `session`. Session cookies, HMAC, and OIDC can also be attached as additional callable dependencies on selected routes. This lets an app use API keys generally and require a session cookie on a browser route, or require both on one route:
@@ -0,0 +1,218 @@
1
+ """Application-level authentication helpers for lenApi projects.
2
+
3
+ Use these factories to connect lenApi's authentication dependencies to a user
4
+ model, rather than putting credential lookup and password handling in
5
+ ``main.py``.
6
+ """
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+
10
+ from .security import (
11
+ APIKeyAuth,
12
+ BasicAuth,
13
+ HMACAuth,
14
+ JWTAuth,
15
+ OIDCAuth,
16
+ OpaqueBearerAuth,
17
+ SessionAuth,
18
+ WebhookAuth,
19
+ check_password,
20
+ hash_api_key,
21
+ make_api_key,
22
+ make_password,
23
+ )
24
+
25
+
26
+ class UserRegistration(BaseModel):
27
+ """Default request schema for registering a project user."""
28
+
29
+ username: str = Field(min_length=1, max_length=120)
30
+ email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", max_length=254)
31
+ first_name: str = Field(default="", max_length=150)
32
+ last_name: str = Field(default="", max_length=150)
33
+ password: str = Field(min_length=8, max_length=128)
34
+ confirm_password: str = Field(min_length=8, max_length=128)
35
+
36
+ @model_validator(mode="after")
37
+ def passwords_must_match(self):
38
+ if self.password != self.confirm_password:
39
+ raise ValueError("password and confirm_password must match")
40
+ return self
41
+
42
+
43
+ class UserPublic(BaseModel):
44
+ """Default public representation returned after user registration."""
45
+
46
+ model_config = ConfigDict(from_attributes=True)
47
+
48
+ id: int
49
+ username: str
50
+ email: str | None
51
+ first_name: str
52
+ last_name: str
53
+ api_key: str | None = None
54
+
55
+
56
+ def _user_authenticator(user_model, identifier_field="username", password_field="password"):
57
+ columns = user_model.__table__.columns
58
+ if identifier_field not in columns:
59
+ raise ValueError(f"Authentication identifier '{identifier_field}' is not a field on {user_model.__name__}")
60
+ if password_field not in columns:
61
+ raise ValueError(f"Password field '{password_field}' is not a field on {user_model.__name__}")
62
+
63
+ def authenticate(identifier, password):
64
+ user = user_model.get(**{identifier_field: identifier}).get()
65
+ if user is not None and check_password(password, getattr(user, password_field)):
66
+ return str(user.id)
67
+ return None
68
+
69
+ return authenticate
70
+
71
+
72
+ def basic(user_model, *, identifier_field="username", password_field="password"):
73
+ """Create database-backed HTTP Basic authentication for a user model."""
74
+ return BasicAuth(_user_authenticator(user_model, identifier_field, password_field))
75
+
76
+
77
+ def api_key(user_model, *, field="api_key", header_name="x-api-key"):
78
+ """Create database-backed API key authentication for a user model.
79
+
80
+ Raw API keys are hashed before lookup. Store only ``hash_api_key(key)`` in
81
+ the configured model field.
82
+ """
83
+ if field not in user_model.__table__.columns:
84
+ raise ValueError(f"API key field '{field}' is not a field on {user_model.__name__}")
85
+
86
+ def authenticate(raw_key):
87
+ if not raw_key:
88
+ return None
89
+ user = user_model.get(**{field: hash_api_key(raw_key)}).get()
90
+ return {"user_id": str(user.id), "auth_method": "api_key"} if user is not None else None
91
+
92
+ return APIKeyAuth(header_name=header_name, authenticate=authenticate)
93
+
94
+
95
+ def jwt_auth(settings, user_model=None, *, identifier_field="username", password_field="password"):
96
+ """Create JWT authentication, optionally backed by a project user model.
97
+
98
+ Use ``auth.router(authenticate)`` to mount token and refresh endpoints.
99
+ """
100
+ token = settings.TOKEN
101
+ auth = JWTAuth(
102
+ token["SECRET_KEY"],
103
+ token_lifetime_seconds=token.get("LIFETIME_SECONDS", 3600),
104
+ refresh_token_lifetime_seconds=token.get("REFRESH_LIFETIME_SECONDS", 60 * 60 * 24 * 30),
105
+ issuer=token.get("ISSUER"),
106
+ authenticate=(
107
+ _user_authenticator(user_model, identifier_field, password_field)
108
+ if user_model is not None else None
109
+ ),
110
+ )
111
+ return auth
112
+
113
+
114
+ def session(*, store=None, lookup=None, cookie_name="lenapi_session"):
115
+ """Create session authentication for use as a route dependency."""
116
+ return SessionAuth(lookup, store=store if lookup is None else None, cookie_name=cookie_name)
117
+
118
+
119
+ def opaque_bearer(*, store=None, lookup=None):
120
+ """Create server-checked opaque bearer token authentication."""
121
+ return OpaqueBearerAuth(lookup, store=store if lookup is None else None)
122
+
123
+
124
+ def hmac(secret, *, tolerance_seconds=300, nonce_store=None):
125
+ """Create optional HMAC request authentication."""
126
+ return HMACAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
127
+
128
+
129
+ def oidc(lookup):
130
+ """Create OIDC authentication backed by a token lookup callback."""
131
+ return OIDCAuth(lookup)
132
+
133
+
134
+ def webhook(secret, *, tolerance_seconds=300, nonce_store=None):
135
+ """Create webhook signature authentication."""
136
+ return WebhookAuth(secret, tolerance_seconds=tolerance_seconds, nonce_store=nonce_store)
137
+
138
+
139
+ def configure(settings, user_model, *, security_store=None):
140
+ """Build the primary authenticator selected by ``settings.AUTHENTICATION``."""
141
+ mode = getattr(settings, "AUTHENTICATION", "jwt").lower()
142
+ identifier_field = getattr(settings, "BASIC_AUTH_FIELD", "username")
143
+ if mode == "basic_login":
144
+ return basic(user_model, identifier_field=identifier_field)
145
+ if mode == "api_key":
146
+ return api_key(user_model)
147
+ if mode == "jwt":
148
+ return jwt_auth(settings, user_model)
149
+ if mode == "session":
150
+ options = getattr(settings, "SESSION", {})
151
+ return session(store=security_store, cookie_name=options.get("COOKIE_NAME", "lenapi_session"))
152
+ if mode == "opaque_bearer":
153
+ return opaque_bearer(store=security_store)
154
+ if mode == "hmac":
155
+ secret = getattr(settings, "HMAC_SECRET", None)
156
+ if not secret:
157
+ raise ValueError("hmac authentication requires HMAC_SECRET")
158
+ return hmac(secret, tolerance_seconds=getattr(settings, "HMAC_TOLERANCE_SECONDS", 300))
159
+ if mode in {"oidc", "oauth2_oidc"}:
160
+ lookup = getattr(settings, "OIDC_TOKEN_LOOKUP", None)
161
+ if lookup is None:
162
+ raise ValueError("oidc authentication requires OIDC_TOKEN_LOOKUP")
163
+ return oidc(lookup)
164
+ raise ValueError(f"Unknown AUTHENTICATION mode: {mode}")
165
+
166
+
167
+ def authentication_type(auth_type, *, settings, user_model, app):
168
+ """Configure an application's selected authentication method in one call.
169
+
170
+ ``auth_type`` may be ``"jwt"``, ``"api_key"``, ``"basic_login``,
171
+ ``"session"``, ``"opaque_bearer"``, ``"hmac"``, or ``"oidc"``. The
172
+ returned object is the dependency to use with ``Depends``. JWT token and
173
+ refresh routes are added to ``app`` automatically.
174
+ """
175
+ from types import SimpleNamespace
176
+
177
+ mode = str(auth_type).lower()
178
+ selected_settings = SimpleNamespace(**{
179
+ key: value for key, value in vars(settings).items() if not key.startswith("__")
180
+ })
181
+ selected_settings.AUTHENTICATION = mode
182
+ authenticator = configure(
183
+ selected_settings,
184
+ user_model,
185
+ security_store=getattr(app, "security_store", None),
186
+ )
187
+ if mode == "jwt":
188
+ app.include_router(authenticator.router())
189
+ return authenticator
190
+
191
+
192
+ def register_user(user_model, data, *, authentication="jwt"):
193
+ """Create a user, hash its password, and return a one-time API key if used."""
194
+ raw_key = make_api_key() if authentication == "api_key" else None
195
+ user = user_model.create(
196
+ username=data.username,
197
+ email=data.email,
198
+ first_name=data.first_name,
199
+ last_name=data.last_name,
200
+ password=make_password(data.password),
201
+ api_key=hash_api_key(raw_key) if raw_key is not None else None,
202
+ )
203
+ return {
204
+ "id": user.id,
205
+ "username": user.username,
206
+ "email": user.email,
207
+ "first_name": user.first_name,
208
+ "last_name": user.last_name,
209
+ "api_key": raw_key,
210
+ }
211
+
212
+
213
+ __all__ = [
214
+ "UserRegistration", "UserPublic", "basic", "api_key", "jwt_auth",
215
+ "session", "opaque_bearer", "hmac", "oidc", "webhook", "configure",
216
+ "authentication_type",
217
+ "register_user",
218
+ ]
@@ -64,82 +64,31 @@ TOKEN = {
64
64
  "ISSUER": API_TITLE,
65
65
  }
66
66
  ''',
67
- "main.py": '''"""Application entry point."""
67
+ "main.py": '''"""Application routes."""
68
68
 
69
- from lenapi import Depends, LenApi, check_password, create_authenticator, hash_api_key, make_api_key, make_password
70
69
  from app import models # Import models so migrations can discover them.
71
- import settings
72
- from pydantic import BaseModel, ConfigDict, Field, model_validator
73
-
74
- app = LenApi()
75
-
76
- if settings.AUTHENTICATION == "basic_login":
77
- identifier_field = getattr(settings, "BASIC_AUTH_FIELD", "username")
78
- if identifier_field not in models.User.__table__.columns:
79
- raise ValueError(f"BASIC_AUTH_FIELD '{identifier_field}' is not a field on User")
80
-
81
-
82
- def authenticate_user(username, password):
83
- user = models.User.get(username=username).get()
84
- if user is not None and check_password(password, user.password):
85
- return str(user.id)
86
- return None
87
-
88
-
89
- def authenticate_api_key(api_key):
90
- if not api_key:
91
- return None
92
- user = models.User.get(api_key=hash_api_key(api_key)).get()
93
- return {"user_id": str(user.id), "auth_method": "api_key"} if user is not None else None
70
+ from app.authentication import app
94
71
 
95
72
 
96
- authenticator = create_authenticator(
97
- settings, authenticate=authenticate_user, authenticate_api_key=authenticate_api_key,
98
- security_store=app.security_store,
99
- )
100
- if settings.AUTHENTICATION == "jwt":
101
- app.include_router(authenticator.router(authenticate_user))
102
-
103
-
104
- class UserRegistration(BaseModel):
105
- username: str = Field(min_length=1, max_length=120)
106
- email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", max_length=254)
107
- first_name: str = Field(default="", max_length=150)
108
- last_name: str = Field(default="", max_length=150)
109
- password: str = Field(min_length=8, max_length=128)
110
- confirm_password: str = Field(min_length=8, max_length=128)
111
-
112
- @model_validator(mode="after")
113
- def passwords_must_match(self):
114
- if self.password != self.confirm_password:
115
- raise ValueError("password and confirm_password must match")
116
- return self
117
-
73
+ @app.get("/")
74
+ def home():
75
+ return {"message": "lenApp is running"}
76
+ ''',
77
+ "app/authentication.py": '''"""Project authentication setup and built-in auth endpoints."""
118
78
 
119
- class UserPublic(BaseModel):
120
- model_config = ConfigDict(from_attributes=True)
79
+ from lenapi import Depends, LenApi
80
+ from lenapi.authentication import UserPublic, UserRegistration, authentication_type, register_user
81
+ from app import models
82
+ import settings
121
83
 
122
- id: int
123
- username: str
124
- email: str | None
125
- first_name: str
126
- last_name: str
127
- api_key: str | None = None
84
+ app = LenApi()
85
+ auth_type = settings.AUTHENTICATION # e.g. "jwt", "api_key", "basic_login", or "session"
86
+ authenticator = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
128
87
 
129
88
 
130
89
  @app.post("/auth/register", response_model=UserPublic)
131
- def register_user(data: UserRegistration):
132
- api_key = make_api_key() if settings.AUTHENTICATION == "api_key" else None
133
- user = models.User.create(
134
- username=data.username,
135
- email=data.email,
136
- first_name=data.first_name,
137
- last_name=data.last_name,
138
- password=make_password(data.password),
139
- api_key=hash_api_key(api_key) if api_key is not None else None,
140
- )
141
- return {"id": user.id, "username": user.username, "email": user.email,
142
- "first_name": user.first_name, "last_name": user.last_name, "api_key": api_key}
90
+ def create_user(data: UserRegistration):
91
+ return register_user(models.User, data, authentication=auth_type)
143
92
 
144
93
 
145
94
  @app.get("/auth/profile")
@@ -147,11 +96,6 @@ def auth_profile(identity=Depends(authenticator)):
147
96
  if isinstance(identity, dict) and "sub" in identity:
148
97
  return {"user_id": identity["sub"]}
149
98
  return {"identity": identity}
150
-
151
-
152
- @app.get("/")
153
- def home():
154
- return {"message": "lenApp is running"}
155
99
  ''',
156
100
  "app/__init__.py": '''"""Application package."""
157
101
  ''',
@@ -328,18 +272,12 @@ python lenora.py migration
328
272
  python lenora.py runserver
329
273
  ```
330
274
 
331
- The development server uses port 8009. Open `/docs/` for Swagger UI. Run `python lenora.py run_test` to run `test.py` and any generated endpoint tests.
275
+ The development server uses port 8009. Open `/docs/` for Swagger UI. Run `python lenora.py test` to generate endpoint checks, run the tests, and write a CSV report.
332
276
  ''',
333
- "test.py": '''"""Starter smoke tests for the application."""
334
-
335
- from main import app
336
-
337
-
338
- def test_root_route_is_registered():
339
- assert any(route.path == "/" and "GET" in route.methods for route in app.routes)
277
+ "tests/__init__.py": '''"""Project test package. Run its tests with ``python lenora.py test``."""
340
278
  ''',
341
279
  "pytest.ini": '''[pytest]
342
- python_files = test.py test_*.py *_test.py
280
+ python_files = test_*.py *_test.py
343
281
  ''',
344
282
  }
345
283
 
@@ -391,6 +329,15 @@ def _main() -> None:
391
329
  help="CSV report path (default: outputs/lenapi_pytest_results.csv)",
392
330
  )
393
331
  run_test_output.add_argument("paths", nargs="*", help="optional pytest files or directories")
332
+ test_command = commands.add_parser(
333
+ "test", help="generate endpoint checks, run tests, and write a CSV report"
334
+ )
335
+ test_command.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string (default: {DEFAULT_APP})")
336
+ test_command.add_argument(
337
+ "--output", default="outputs/lenapi_test_results.csv",
338
+ help="CSV report path (default: outputs/lenapi_test_results.csv)",
339
+ )
340
+ test_command.add_argument("paths", nargs="*", help="optional pytest files or directories")
394
341
 
395
342
  args = parser.parse_args()
396
343
 
@@ -427,7 +374,7 @@ def _main() -> None:
427
374
  print(f"Next: cd {args.name} && python lenora.py migration && python lenora.py runserver")
428
375
  return
429
376
 
430
- if args.command in {"generate_test", "run_test", "run_test_output"}:
377
+ if args.command in {"generate_test", "run_test", "run_test_output", "test"}:
431
378
  module_name, separator, attribute_path = args.app.partition(":")
432
379
  if not separator or not module_name:
433
380
  parser.error("--app must use the form package.module:app")
@@ -443,10 +390,15 @@ def _main() -> None:
443
390
  for method in route.methods
444
391
  )
445
392
  websocket_endpoints = sorted(route.path for route in app.websocket_routes)
446
- if args.command == "generate_test":
447
- test_dir = Path.cwd() / "tests"
448
- test_dir.mkdir(parents=True, exist_ok=True)
449
- destination = test_dir / "test_generated_endpoints.py"
393
+ generated_test_temp = None
394
+ if args.command in {"generate_test", "test"}:
395
+ if args.command == "generate_test":
396
+ test_dir = Path.cwd() / "tests"
397
+ test_dir.mkdir(parents=True, exist_ok=True)
398
+ destination = test_dir / "test_generated_endpoints.py"
399
+ else:
400
+ generated_test_temp = tempfile.TemporaryDirectory(prefix="lenapi-generated-tests-")
401
+ destination = Path(generated_test_temp.name) / "test_generated_endpoints.py"
450
402
  websocket_test_source = (
451
403
  '''@pytest.mark.parametrize("path", WEBSOCKET_ENDPOINTS)
452
404
  def test_websocket_endpoint_is_registered(path):
@@ -455,7 +407,7 @@ def test_websocket_endpoint_is_registered(path):
455
407
  '''
456
408
  if websocket_endpoints else ""
457
409
  )
458
- generated_source = f'''"""Generated endpoint registration and OpenAPI coverage. Regenerate with python lenora.py generate_test."""
410
+ generated_source = f'''"""Generated endpoint registration and OpenAPI coverage."""
459
411
 
460
412
  from importlib import import_module
461
413
 
@@ -486,13 +438,16 @@ def test_http_endpoint_is_registered_and_documented(method, path):
486
438
  {websocket_test_source}
487
439
  '''
488
440
  destination.write_text(generated_source, encoding="utf-8")
489
- print(f"Generated {destination}")
441
+ print(f"Generated endpoint checks: {len(http_endpoints) + len(websocket_endpoints)}")
442
+ if args.command == "generate_test":
443
+ print(f"Generated {destination}")
490
444
  print(f"HTTP endpoints covered: {len(http_endpoints)}")
491
445
  for method, path in http_endpoints:
492
446
  print(f" {method.upper():7} {path}")
493
447
  for path in websocket_endpoints:
494
448
  print(f" WEBSOCKET {path}")
495
- return
449
+ if args.command == "generate_test":
450
+ return
496
451
 
497
452
  print("Endpoints covered by generated route tests:")
498
453
  for method, path in http_endpoints:
@@ -501,7 +456,17 @@ def test_http_endpoint_is_registered_and_documented(method, path):
501
456
  print(f" WEBSOCKET {path}")
502
457
  sys.stdout.flush()
503
458
 
504
- if args.paths:
459
+ if args.command == "test":
460
+ pytest_args = [*args.paths]
461
+ if not pytest_args and (Path.cwd() / "tests").is_dir():
462
+ pytest_args.append("tests")
463
+ pytest_args.append(str(destination))
464
+ project_dir = Path.cwd().resolve()
465
+ pytest_args = [
466
+ str((project_dir / path).resolve()) if not Path(path).is_absolute() else path
467
+ for path in pytest_args
468
+ ]
469
+ elif args.paths:
505
470
  pytest_args = [*args.paths]
506
471
  else:
507
472
  pytest_args = []
@@ -512,11 +477,22 @@ def test_http_endpoint_is_registered_and_documented(method, path):
512
477
  if not pytest_args:
513
478
  pytest_args = ["tests"]
514
479
  command = [sys.executable, "-m", "pytest", "-q"]
515
- if args.command == "run_test_output":
480
+ if args.command in {"run_test_output", "test"}:
516
481
  with tempfile.TemporaryDirectory(prefix="lenapi-pytest-") as temp_dir:
517
482
  junit_path = Path(temp_dir) / "results.xml"
483
+ run_options = {}
484
+ if args.command == "test":
485
+ project_dir = Path.cwd().resolve()
486
+ framework_root = str(Path(__file__).resolve().parents[1])
487
+ environment = os.environ.copy()
488
+ search_paths = [framework_root, str(project_dir)]
489
+ if environment.get("PYTHONPATH"):
490
+ search_paths.append(environment["PYTHONPATH"])
491
+ environment["PYTHONPATH"] = os.pathsep.join(search_paths)
492
+ run_options = {"cwd": project_dir.parent, "env": environment}
518
493
  result = subprocess.run(
519
- [*command, "--junitxml", str(junit_path), *pytest_args], check=False
494
+ [*command, "--junitxml", str(junit_path), *pytest_args], check=False,
495
+ **run_options,
520
496
  )
521
497
  suites = ET.parse(junit_path).getroot()
522
498
  cases = suites.findall(".//testcase")
@@ -559,6 +535,8 @@ def test_http_endpoint_is_registered_and_documented(method, path):
559
535
  print(f"CSV report written to {report_path}")
560
536
  else:
561
537
  result = subprocess.run([*command, *pytest_args], check=False)
538
+ if generated_test_temp is not None:
539
+ generated_test_temp.cleanup()
562
540
  raise SystemExit(result.returncode)
563
541
 
564
542
  if args.command == "runserver":
@@ -115,6 +115,7 @@ class JWTAuth:
115
115
  token_lifetime_seconds: int = 3600,
116
116
  refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30,
117
117
  issuer: str | None = None,
118
+ authenticate=None,
118
119
  ) -> None:
119
120
  key = secret_key.encode("utf-8") if isinstance(secret_key, str) else secret_key
120
121
  if not isinstance(key, bytes) or len(key) < 32:
@@ -127,6 +128,7 @@ class JWTAuth:
127
128
  self.token_lifetime_seconds = token_lifetime_seconds
128
129
  self.refresh_token_lifetime_seconds = refresh_token_lifetime_seconds
129
130
  self.issuer = issuer
131
+ self.authenticate = authenticate
130
132
 
131
133
  def create_access_token(
132
134
  self,
@@ -187,14 +189,19 @@ class JWTAuth:
187
189
  if key not in {"sub", "iat", "exp", "iss", "token_use"}}
188
190
  return self.create_access_token(payload["sub"], claims=claims)
189
191
 
190
- def router(self, authenticate):
192
+ def router(self, authenticate=None):
191
193
  """Build `/auth/token` and `/auth/refresh` routes for an app.
192
194
 
193
- `authenticate(username, password)` must return the user's string ID, or
195
+ `authenticate(username, password)` may be supplied here or when
196
+ constructing the instance. It must return the user's string ID, or
194
197
  `(string_id, claims)`, when credentials are valid; otherwise return None.
195
198
  """
196
199
  from .application import Router
197
200
 
201
+ authenticate = authenticate or self.authenticate
202
+ if authenticate is None:
203
+ raise ValueError("JWT token routes require an authenticate(username, password) callback")
204
+
198
205
  router = Router(prefix="/auth")
199
206
 
200
207
  @router.post("/token")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lenapi
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A small, typed ASGI web framework
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -214,13 +214,13 @@ The repository root contains the lenApi library. Create an application scaffold
214
214
 
215
215
  ```bash
216
216
  python -m pip install -e '.[server]'
217
- python lenora.py startproject my_api
217
+ lenora startproject my_api
218
218
  cd my_api
219
219
  python lenora.py migration
220
220
  python lenora.py runserver
221
221
  ```
222
222
 
223
- The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `settings.py`, `main.py`, `test.py`, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
223
+ Use the installed `lenora` command to create the first project; `python lenora.py ...` commands are available after entering the generated project. The server listens on `127.0.0.1:8009` by default. The generated application contains `app/models.py`, `app/authentication.py`, `settings.py`, `main.py`, a `tests/` package, a project-local CLI launcher, `alembic.ini`, and a `migrations/` folder. The source tree keeps working applications under `app/`; they are separate from the installable `lenapi` package.
224
224
 
225
225
  `settings.py` centralizes the API title/version, docs and OpenAPI URLs, host, port, database URL, token settings, and shared security storage. `app = LenApi()` loads these project settings automatically, including the title displayed by Swagger. Each generated project gets a unique high-entropy `SECRET_KEY`; `TOKEN` uses it to sign tokens and configures token lifetime and issuer. Keep the key private. Changing it invalidates tokens signed with the old key. Override the host, port, or SQLite URL with `LENAPI_HOST`, `LENAPI_PORT`, and `LENAPI_DATABASE_URL` environment variables.
226
226
 
@@ -235,7 +235,7 @@ python -m pip install -e '.[test]'
235
235
  python -m pytest
236
236
  ```
237
237
 
238
- The `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `run_test_output` writes a CSV report under that application directory.
238
+ The `test`, `generate_test`, `run_test`, and `run_test_output` commands are application commands. Run them from an application directory, where `main.py` and `settings.py` live. `test` generates endpoint checks temporarily, runs them, and writes a CSV report under `outputs/`. `run_test_output` also writes a CSV report.
239
239
 
240
240
  For the included Student API example, run those commands from `app/student_api`:
241
241
 
@@ -243,6 +243,7 @@ For the included Student API example, run those commands from `app/student_api`:
243
243
  cd app/student_api
244
244
  python lenora.py generate_test
245
245
  python lenora.py run_test
246
+ python lenora.py test
246
247
  ```
247
248
 
248
249
  Request bodies are limited to 10 MiB by default, and lenApi allows up to 30 seconds to receive the body. Configure `MAX_REQUEST_BODY_SIZE` (bytes) and `REQUEST_BODY_TIMEOUT_SECONDS` in `settings.py`; set either to `None` to disable that limit. Oversized bodies receive HTTP 413, and bodies that exceed the receive timeout receive HTTP 408. Direct `Application(...)` instances accept the equivalent `max_request_body_size` and `request_body_timeout` arguments.
@@ -424,20 +425,20 @@ BASIC_AUTH_FIELD = "email" # username, email, phone, or another User field
424
425
 
425
426
  The HTTP Basic username slot is treated as the configured identifier, so a client can send `auth=(email, password)` or `auth=(phone, password)`. For a custom identifier, add that field to `User` and migrate the database; then set `BASIC_AUTH_FIELD` to the model field name.
426
427
 
427
- The generated `main.py` uses that setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh`. `create_authenticator(settings, ...)` builds the selected dependency; provide callbacks for database-backed credentials and stores:
428
+ The generated `main.py` uses the selected setting to protect `/auth/profile`; Swagger shows the selected scheme. JWT mode also registers `/auth/token` and `/auth/refresh` automatically. Initialize the selected method through `authentication_type`:
428
429
 
429
430
  ```python
430
- from lenapi import Depends, create_authenticator
431
+ from lenapi import Depends
432
+ from lenapi.authentication import authentication_type
433
+ from app import models
431
434
  import settings
432
435
 
433
- auth = create_authenticator(settings, authenticate=authenticate_user)
436
+ auth_type = settings.AUTHENTICATION # "jwt", "api_key", "basic_login", "session", etc.
437
+ auth = authentication_type(auth_type, settings=settings, user_model=models.User, app=app)
434
438
 
435
439
  @app.get("/protected")
436
440
  def protected(user=Depends(auth)):
437
441
  return {"authenticated": True}
438
-
439
- if settings.AUTHENTICATION == "jwt":
440
- app.include_router(auth.router(authenticate_user))
441
442
  ```
442
443
 
443
444
  The primary authentication setting chooses one method for the scaffold's default protected route: `jwt`, `api_key`, `basic_login`, `opaque_bearer`, or `session`. Session cookies, HMAC, and OIDC can also be attached as additional callable dependencies on selected routes. This lets an app use API keys generally and require a session cookie on a browser route, or require both on one route:
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  lenapi/__init__.py
4
4
  lenapi/application.py
5
+ lenapi/authentication.py
5
6
  lenapi/authorization.py
6
7
  lenapi/background.py
7
8
  lenapi/cli.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lenapi"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "A small, typed ASGI web framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
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