This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/arm by this push:
     new b451f601 Move most of the admin routes over to "typed" decorators for 
param validation. Add test to make sure future decorator changes don't break 
routing. Simplify param checking.
b451f601 is described below

commit b451f601081456f8f6fd60ef5da289a6830a44c3
Author: Alastair McFarlane <[email protected]>
AuthorDate: Thu Mar 19 14:37:11 2026 +0000

    Move most of the admin routes over to "typed" decorators for param 
validation. Add test to make sure future decorator changes don't break routing. 
Simplify param checking.
---
 atr/admin/__init__.py         | 409 +++++++++++++++++++++++++++++-------------
 atr/blueprints/admin.py       | 128 ++++++++++++-
 atr/blueprints/api.py         |  67 ++-----
 atr/blueprints/common.py      | 133 ++++++++++++--
 atr/get/test.py               |   7 +-
 tests/unit/test_blueprints.py |  62 +++++++
 6 files changed, 602 insertions(+), 204 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 3ceb3bf1..1ce05888 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -53,6 +53,7 @@ import atr.mapping as mapping
 import atr.models.safe as safe
 import atr.models.session
 import atr.models.sql as sql
+import atr.models.unsafe as unsafe
 import atr.paths as paths
 import atr.principal as principal
 import atr.storage as storage
@@ -126,17 +127,25 @@ class SessionDataCommon(NamedTuple):
     projects: list[str]
 
 
[email protected]("/all-releases")
-async def all_releases(session: web.Committer) -> str:
-    """Display a list of all releases across all phases."""
[email protected]
+async def all_releases(_session: web.Committer, _all_releases: 
Literal["all/releases"]) -> str:
+    """
+    URL: GET /all/releases
+
+    Display a list of all releases across all phases.
+    """
     async with db.session() as data:
         releases = await data.release(_project=True, 
_committee=True).order_by(sql.Release.key).all()
     return await template.render("all-releases.html", releases=releases, 
release_as_url=mapping.release_as_url)
 
 
[email protected]("/browse-as")
-async def browse_as_get(session: web.Committer) -> str | web.WerkzeugResponse:
-    """Allows an admin to browse as another user."""
[email protected]
+async def browse_as_get(_session: web.Committer, _browse_as: 
Literal["browse-as"]) -> str | web.WerkzeugResponse:
+    """
+    URL: GET /browse-as
+
+    Allows an admin to browse as another user.
+    """
     rendered_form = form.render(
         model_cls=BrowseAsUserForm,
         submit_label="Browse as this user",
@@ -144,10 +153,15 @@ async def browse_as_get(session: web.Committer) -> str | 
web.WerkzeugResponse:
     return await template.render("browse-as.html", form=rendered_form)
 
 
[email protected]("/browse-as")
[email protected](BrowseAsUserForm)
-async def browse_as_post(session: web.Committer, browse_form: 
BrowseAsUserForm) -> str | web.WerkzeugResponse:
-    """Allows an admin to browse as another user."""
[email protected]
+async def browse_as_post(
+    session: web.Committer, _browse_as: Literal["browse-as"], browse_form: 
BrowseAsUserForm
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /browse-as
+
+    Allows an admin to browse as another user.
+    """
     # We specifically need to use this on the production server
     import atr.get.root as root
 
@@ -194,9 +208,13 @@ async def browse_as_post(session: web.Committer, 
browse_form: BrowseAsUserForm)
     return await session.redirect(root.index)
 
 
[email protected]("/configuration")
-async def configuration(session: web.Committer) -> web.QuartResponse:
-    """Display the current application configuration values."""
[email protected]
+async def configuration(_session: web.Committer, _configuration: 
Literal["configuration"]) -> web.QuartResponse:
+    """
+    URL: GET /configuration
+
+    Display the current application configuration values.
+    """
 
     sensitive_config_patterns = ("_PASSWORD", "_KEY", "_TOKEN", "_SECRET")
 
@@ -219,9 +237,13 @@ async def configuration(session: web.Committer) -> 
web.QuartResponse:
     return web.TextResponse("\n".join(values))
 
 
[email protected]("/consistency")
-async def consistency(session: web.Committer) -> web.TextResponse:
-    """Check for consistency between the database and the filesystem."""
[email protected]
+async def consistency(_session: web.Committer, _consistency: 
Literal["consistency"]) -> web.TextResponse:
+    """
+    URL: GET /consistency
+
+    Check for consistency between the database and the filesystem.
+    """
     # Get all releases from the database
     async with db.session() as data:
         releases = await data.release().all()
@@ -264,19 +286,34 @@ async def consistency(session: web.Committer) -> 
web.TextResponse:
     )
 
 
[email protected]("/data")
-async def data(session: web.Committer) -> str:
-    return await _data(session, "Committee")
[email protected]
+async def data(session: web.Committer, _data: Literal["data"]) -> str:
+    """
+    URL: GET /data
+    """
+    return await _data_browse(session, "Committee")
 
 
[email protected]("/data/<model>")
-async def data_model(session: web.Committer, model: str = "Committee") -> str:
-    return await _data(session, model)
[email protected]
+async def data_model(
+    session: web.Committer, _data: Literal["data"], model: unsafe.UnsafeStr = 
unsafe.UnsafeStr("Committee")
+) -> str:
+    """
+    URL: GET /data/<model>
+    """
 
+    return await _data_browse(session, str(model))
 
[email protected]("/delete-committee-keys")
-async def delete_committee_keys_get(session: web.Committer) -> str | 
web.WerkzeugResponse:
-    """Display the form to delete committee keys."""
+
[email protected]
+async def delete_committee_keys_get(
+    _session: web.Committer, _delete_committee_keys: 
Literal["delete-committee-keys"]
+) -> str | web.WerkzeugResponse:
+    """
+    URL: GET /delete-committee-keys
+
+    Display the form to delete committee keys.
+    """
     async with db.session() as data:
         all_committees = await 
data.committee(_public_signing_keys=True).order_by(sql.Committee.key).all()
         committees_with_keys = [c for c in all_committees if 
c.public_signing_keys]
@@ -291,12 +328,17 @@ async def delete_committee_keys_get(session: 
web.Committer) -> str | web.Werkzeu
     return await template.render("delete-committee-keys.html", 
form=rendered_form)
 
 
[email protected]("/delete-committee-keys")
[email protected](DeleteCommitteeKeysForm)
[email protected]
 async def delete_committee_keys_post(
-    session: web.Committer, delete_form: DeleteCommitteeKeysForm
+    session: web.Committer,
+    _delete_committee_keys: Literal["delete-committee-keys"],
+    delete_form: DeleteCommitteeKeysForm,
 ) -> str | web.WerkzeugResponse:
-    """Delete all keys for selected committee."""
+    """
+    URL: POST /delete-committee-keys
+
+    Delete all keys for selected committee.
+    """
     committee_key = delete_form.committee_key
 
     async with db.session() as data:
@@ -336,9 +378,15 @@ async def delete_committee_keys_post(
     return await session.redirect(delete_committee_keys_get)
 
 
[email protected]("/delete-release")
-async def delete_release_get(session: web.Committer) -> str | 
web.WerkzeugResponse:
-    """Display the form to delete releases."""
[email protected]
+async def delete_release_get(
+    _session: web.Committer, _delete_release_get: Literal["delete-release"]
+) -> str | web.WerkzeugResponse:
+    """
+    URL: GET /delete-release
+
+    Display the form to delete releases.
+    """
     async with db.session() as data:
         releases = await 
data.release(_project=True).order_by(sql.Release.key).all()
 
@@ -378,20 +426,31 @@ async def delete_release_get(session: web.Committer) -> 
str | web.WerkzeugRespon
     return await template.render("delete-release.html", form=rendered_form)
 
 
[email protected]("/delete-release")
[email protected](DeleteReleaseForm)
-async def delete_release_post(session: web.Committer, delete_form: 
DeleteReleaseForm) -> str | web.WerkzeugResponse:
-    """Delete selected releases and their associated data and files."""
[email protected]
+async def delete_release_post(
+    session: web.Committer, _delete_release_get: Literal["delete-release"], 
delete_form: DeleteReleaseForm
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /delete-release
+
+    Delete selected releases and their associated data and files.
+    """
     await _delete_releases(session, delete_form.releases_to_delete)
 
     return await session.redirect(delete_release_get)
 
 
[email protected]("/delete-test-openpgp-keys")
-async def delete_test_openpgp_keys_get(session: web.Committer) -> web.Response:
-    """Display the form to delete test user OpenPGP keys."""
[email protected]
+async def delete_test_openpgp_keys_get(
+    _session: web.Committer, _delete_test_openpgp_keys: 
Literal["delete-test-openpgp-keys"]
+) -> web.Response:
+    """
+    URL: GET /delete-test-openpgp-keys
+
+    Display the form to delete test user OpenPGP keys.
+    """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
+        return quart.abort(404)
 
     rendered_form = form.render(
         model_cls=form.Empty,
@@ -401,12 +460,17 @@ async def delete_test_openpgp_keys_get(session: 
web.Committer) -> web.Response:
     return web.ElementResponse(rendered_form)
 
 
[email protected]("/delete-test-openpgp-keys")
[email protected]()
-async def delete_test_openpgp_keys_post(session: web.Committer) -> 
web.Response:
-    """Delete all test user OpenPGP keys and their links."""
[email protected]
+async def delete_test_openpgp_keys_post(
+    session: web.Committer, _delete_test_openpgp_keys: 
Literal["delete-test-openpgp-keys"], _form: form.Empty
+) -> web.Response:
+    """
+    URL: POST /delete-test-openpgp-keys
+
+    Delete all test user OpenPGP keys and their links.
+    """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
+        return quart.abort(404)
 
     test_uid = "test"
     try:
@@ -427,18 +491,26 @@ async def delete_test_openpgp_keys_post(session: 
web.Committer) -> web.Response:
     return await session.redirect(get.keys.keys)
 
 
[email protected]("/env")
-async def env(session: web.Committer) -> web.QuartResponse:
-    """Display the environment variables."""
[email protected]
+async def env(_session: web.Committer, _env: Literal["env"]) -> 
web.QuartResponse:
+    """
+    URL: GET /env
+
+    Display the environment variables.
+    """
     env_vars = []
     for key, value in os.environ.items():
         env_vars.append(f"{key}={value}")
     return web.TextResponse("\n".join(env_vars))
 
 
[email protected]("/keys/check")
-async def keys_check_get(session: web.Committer) -> web.QuartResponse:
-    """Check public signing key details."""
[email protected]
+async def keys_check_get(_session: web.Committer, _keys_check: 
Literal["keys/check"]) -> web.QuartResponse:
+    """
+    URL: GET /keys/check
+
+    Check public signing key details.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Check public signing key details",
@@ -447,6 +519,7 @@ async def keys_check_get(session: web.Committer) -> 
web.QuartResponse:
     return web.ElementResponse(rendered_form)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/keys/check")
 async def keys_check_post(session: web.Committer) -> web.QuartResponse:
     """Check public signing key details."""
@@ -458,9 +531,15 @@ async def keys_check_post(session: web.Committer) -> 
web.QuartResponse:
         return web.TextResponse(f"Exception during key check: {e!s}")
 
 
[email protected]("/keys/regenerate-all")
-async def keys_regenerate_all_get(session: web.Committer) -> web.QuartResponse:
-    """Display the form to regenerate KEYS files."""
[email protected]
+async def keys_regenerate_all_get(
+    _session: web.Committer, _keys_regenerate_all: 
Literal["keys/regenerate-all"]
+) -> web.QuartResponse:
+    """
+    URL: GET /keys/regenerate-all
+
+    Display the form to regenerate KEYS files.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Regenerate all KEYS files",
@@ -469,6 +548,7 @@ async def keys_regenerate_all_get(session: web.Committer) 
-> web.QuartResponse:
     return web.ElementResponse(rendered_form)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/keys/regenerate-all")
 async def keys_regenerate_all_post(session: web.Committer) -> 
web.QuartResponse:
     """Regenerate the KEYS file for all committees."""
@@ -493,9 +573,15 @@ async def keys_regenerate_all_post(session: web.Committer) 
-> web.QuartResponse:
     return web.TextResponse("\n".join(response_lines))
 
 
[email protected]("/keys/update")
-async def keys_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    """Update keys from remote data."""
[email protected]
+async def keys_update_get(
+    _session: web.Committer, _keys_update: Literal["keys/update"]
+) -> str | web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
+    """
+    URL: GET /keys/update
+
+    Update keys from remote data.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Update keys",
@@ -512,6 +598,7 @@ async def keys_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse
     return await template.render("update-keys.html", empty_form=rendered_form, 
previous_output=previous_output)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/keys/update")
 async def keys_update_post(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
     """Update keys from remote data."""
@@ -531,8 +618,11 @@ async def keys_update_post(session: web.Committer) -> str 
| web.WerkzeugResponse
         }, 200
 
 
[email protected]("/ldap")
-async def ldap_get(session: web.Committer) -> str:
[email protected]
+async def ldap_get(session: web.Committer, _ldap: Literal["ldap"]) -> str:
+    """
+    URL: GET /ldap
+    """
     rendered_form = form.render(
         model_cls=LdapLookupForm,
         submit_label="Lookup",
@@ -547,9 +637,11 @@ async def ldap_get(session: web.Committer) -> str:
     )
 
 
[email protected]("/ldap")
[email protected](LdapLookupForm)
-async def ldap_post(session: web.Committer, lookup_form: LdapLookupForm) -> 
str:
[email protected]
+async def ldap_post(session: web.Committer, _ldap: Literal["ldap"], 
lookup_form: LdapLookupForm) -> str:
+    """
+    URL POST /ldap
+    """
     # TODO: This is one case where we should perhaps allow str | None on the 
form
     uid_query = lookup_form.uid if lookup_form.uid else None
     email_query = lookup_form.email if lookup_form.email else None
@@ -586,8 +678,11 @@ async def ldap_post(session: web.Committer, lookup_form: 
LdapLookupForm) -> str:
     )
 
 
[email protected]("/logs")
-async def logs(session: web.Committer) -> web.QuartResponse:
[email protected]
+async def logs(_session: web.Committer, _logs: Literal["logs"]) -> 
web.QuartResponse:
+    """
+    URL: GET /logs
+    """
     _require_debug_and_allow_tests()
     recent_logs = log.get_recent_logs()
     if recent_logs is None:
@@ -595,29 +690,35 @@ async def logs(session: web.Committer) -> 
web.QuartResponse:
     return web.TextResponse("\n".join(recent_logs))
 
 
[email protected]("/ongoing-tasks/<project_key>/<version_key>/<revision>")
[email protected]
 async def ongoing_tasks_get(
-    session: web.Committer, project_key: str, version_key: str, revision: str
+    session: web.Committer,
+    _ongoing_tasks: Literal["ongoing-tasks"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    revision: safe.RevisionNumber,
 ) -> web.QuartResponse:
-    project = safe.ProjectKey(project_key)
-    version = safe.VersionKey(version_key)
-    revision_number = safe.RevisionNumber(revision)
-    return await _ongoing_tasks(session, project, version, revision_number)
+    """
+    URL: GET /ongoing-tasks
+    """
+    return await _fetch_ongoing_tasks(session, project_key, version_key, 
revision)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/ongoing-tasks/<project_key>/<version_key>/<revision>")
 async def ongoing_tasks_post(
-    session: web.Committer, project_key: str, version_key: str, revision: str
+    session: web.Committer, project_key: safe.ProjectKey, version_key: 
safe.VersionKey, revision: safe.RevisionNumber
 ) -> web.QuartResponse:
-    project = safe.ProjectKey(project_key)
-    version = safe.VersionKey(version_key)
-    revision_number = safe.RevisionNumber(revision)
-    return await _ongoing_tasks(session, project, version, revision_number)
+    return await _fetch_ongoing_tasks(session, project_key, version_key, 
revision)
+
 
[email protected]
+async def performance(_session: web.Committer, _performance: 
Literal["performance"]) -> str:
+    """
+    URL: GET /performance
 
[email protected]("/performance")
-async def performance(session: web.Committer) -> str:
-    """Display performance statistics for all routes."""
+    Display performance statistics for all routes.
+    """
     app = asfquart.APP
 
     if app is ...:
@@ -696,9 +797,15 @@ async def performance(session: web.Committer) -> str:
     return await template.render("performance.html", stats=sorted_summary)
 
 
[email protected]("/projects/update")
-async def projects_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    """Update projects from remote data."""
[email protected]
+async def projects_update_get(
+    _session: web.Committer, _projects_update: Literal["projects/update"]
+) -> str | web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
+    """
+    URL: GET /projects/update
+
+    Update projects from remote data.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Update projects",
@@ -708,6 +815,7 @@ async def projects_update_get(session: web.Committer) -> 
str | web.WerkzeugRespo
     return await template.render("update-projects.html", 
empty_form=rendered_form)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/projects/update")
 async def projects_update_post(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
     """Update projects from remote data."""
@@ -725,14 +833,21 @@ async def projects_update_post(session: web.Committer) -> 
str | web.WerkzeugResp
         }, 200
 
 
[email protected]("/raise-error")
-async def raise_error(session: web.Committer) -> web.QuartResponse:
[email protected]
+async def raise_error(_session: web.Committer, _raise_error: 
Literal["raise-error"]) -> web.QuartResponse:
+    """
+    URL: GET /raise-error
+    """
     raise RuntimeError("Admin test route deliberately raised an unhandled 
error")
 
 
[email protected]("/revoke-user-tokens")
-async def revoke_user_tokens_get(session: web.Committer) -> str:
-    """Revoke all Personal Access Tokens for a specified user."""
[email protected]
+async def revoke_user_tokens_get(_session: web.Committer, _revoke_user_tokens: 
Literal["revoke-user-tokens"]) -> str:
+    """
+    URL: GET /revoke-user-tokens
+
+    Revoke all Personal Access Tokens for a specified user.
+    """
     token_counts: list[tuple[str, int]] = []
     async with db.session() as data:
         stmt = (
@@ -757,12 +872,15 @@ async def revoke_user_tokens_get(session: web.Committer) 
-> str:
     )
 
 
[email protected]("/revoke-user-tokens")
[email protected](RevokeUserTokensForm)
[email protected]
 async def revoke_user_tokens_post(
-    session: web.Committer, revoke_form: RevokeUserTokensForm
+    session: web.Committer, _revoke_user_tokens: 
Literal["revoke-user-tokens"], revoke_form: RevokeUserTokensForm
 ) -> str | web.WerkzeugResponse:
-    """Revoke all Personal Access Tokens for a specified user."""
+    """
+    URL: POST /revoke-user-tokens
+
+    Revoke all Personal Access Tokens for a specified user.
+    """
     target_uid = revoke_form.asf_uid
 
     async with storage.write(session) as write:
@@ -777,8 +895,11 @@ async def revoke_user_tokens_post(
     return await session.redirect(revoke_user_tokens_get)
 
 
[email protected]("/rotate-jwt-key")
-async def rotate_jwt_key_get(session: web.Committer) -> str:
[email protected]
+async def rotate_jwt_key_get(_session: web.Committer, _rotate_jwt_key: 
Literal["rotate-jwt-key"]) -> str:
+    """
+    URL: GET /rotate-jwt-key
+    """
     rendered_form = form.render(
         model_cls=RotateJwtKeyForm,
         submit_label="Rotate JWT key",
@@ -786,9 +907,13 @@ async def rotate_jwt_key_get(session: web.Committer) -> 
str:
     return await _rotate_jwt_key_page(rendered_form)
 
 
[email protected]("/rotate-jwt-key")
[email protected](RotateJwtKeyForm)
-async def rotate_jwt_key_post(session: web.Committer, _rotate_form: 
RotateJwtKeyForm) -> str | web.WerkzeugResponse:
[email protected]
+async def rotate_jwt_key_post(
+    session: web.Committer, _rotate_jwt_key: Literal["rotate-jwt-key"], 
_rotate_form: RotateJwtKeyForm
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /rotate-jwt-key
+    """
     async with storage.write(session) as write:
         wafa = write.as_foundation_admin()
         await wafa.tokens.rotate_jwt_signing_key()
@@ -796,16 +921,23 @@ async def rotate_jwt_key_post(session: web.Committer, 
_rotate_form: RotateJwtKey
     return await session.redirect(rotate_jwt_key_get)
 
 
[email protected]("/task-times/<project_key>/<version_key>/<revision_number>")
[email protected]
 async def task_times(
-    session: web.Committer, project_key: str, version_key: str, 
revision_number: str
+    _session: web.Committer,
+    _task_times: Literal["task-times"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    revision_number: safe.RevisionNumber,
 ) -> web.QuartResponse:
+    """
+    URL: GET /task-times/<project_key>/<version_key>/<revision_number>
+    """
     values = []
     async with db.session() as data:
         tasks = await data.task(
-            project_key=project_key,
-            version_key=version_key,
-            revision_number=revision_number,
+            project_key=str(project_key),
+            version_key=str(version_key),
+            revision_number=str(revision_number),
         ).all()
         for task in tasks:
             if (task.started is None) or (task.completed is None):
@@ -816,9 +948,13 @@ async def task_times(
     return web.TextResponse("\n".join(values))
 
 
[email protected]("/tasks/recent/<int:minutes>")
-async def tasks_recent(session: web.Committer, minutes: int) -> str:
-    """Display tasks from the last N minutes."""
[email protected]
+async def tasks_recent(_session: web.Committer, _tasks_recent: 
Literal["tasks/recent"], minutes: int) -> str:
+    """
+    URL: GET /tasks/recent/<int:minutes>
+
+    Display tasks from the last N minutes.
+    """
     if minutes < 1:
         minutes = 1
     if minutes > 1440:
@@ -929,9 +1065,13 @@ async def tasks_recent(session: web.Committer, minutes: 
int) -> str:
     return await template.blank(f"Recent Tasks ({minutes}m)", 
content=page.collect())
 
 
[email protected]("/toggle-view")
-async def toggle_view_get(session: web.Committer) -> str:
-    """Display the page with a button to toggle between admin and user 
views."""
[email protected]
+async def toggle_view_get(_session: web.Committer, _toggle_view: 
Literal["toggle-view"]) -> str:
+    """
+    URL: GET /toggle-view
+
+    Display the page with a button to toggle between admin and user views.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Toggle view",
@@ -941,10 +1081,15 @@ async def toggle_view_get(session: web.Committer) -> str:
     return await template.render("toggle-admin-view.html", 
empty_form=rendered_form)
 
 
[email protected]("/toggle-view")
[email protected]()
-async def toggle_view_post(session: web.Committer) -> web.WerkzeugResponse:
-    """Toggle between admin and user views."""
[email protected]
+async def toggle_view_post(
+    _session: web.Committer, _toggle_view: Literal["toggle-view"], _form: 
form.Empty
+) -> web.WerkzeugResponse:
+    """
+    URL: POST /toggle-view
+
+    Toggle between admin and user views.
+    """
     app = asfquart.APP
     if (not hasattr(app, "app_id")) or (not isinstance(app.app_id, str)):
         raise TypeError("Internal error: APP has no valid app_id")
@@ -962,9 +1107,13 @@ async def toggle_view_post(session: web.Committer) -> 
web.WerkzeugResponse:
     return quart.redirect("https://"; + quart.request.host + "/")
 
 
[email protected]("/validate")
-async def validate_(session: web.Committer) -> str:
-    """Run validators and display any divergences."""
[email protected]
+async def validate_(_session: web.Committer, _validate: Literal["validate"]) 
-> str:
+    """
+    URL: GET /validate
+
+    Run validators and display any divergences.
+    """
 
     async with db.session() as data:
         divergences = [d async for d in validate.everything(data)]
@@ -975,9 +1124,15 @@ async def validate_(session: web.Committer) -> str:
     )
 
 
[email protected]("/validate-jwt")
-async def validate_jwt_get(session: web.Committer) -> str:
-    _require_debug_and_allow_tests()
[email protected]
+async def validate_jwt_get(_session: web.Committer, _validate_jwt: 
Literal["validate-jwt"]) -> str:
+    """
+    URL: GET /validate-jwt
+    """
+    try:
+        _require_debug_and_allow_tests()
+    except base.ASFQuartException:
+        return quart.abort(404)
     rendered_form = form.render(
         model_cls=ValidateJwtForm,
         submit_label="Validate JWT",
@@ -986,10 +1141,18 @@ async def validate_jwt_get(session: web.Committer) -> 
str:
     return await _validate_jwt_page(rendered_form, result=None)
 
 
[email protected]("/validate-jwt")
[email protected](ValidateJwtForm)
-async def validate_jwt_post(session: web.Committer, validate_form: 
ValidateJwtForm) -> str:
-    _require_debug_and_allow_tests()
[email protected]
+async def validate_jwt_post(
+    _session: web.Committer, _validate_jwt: Literal["validate-jwt"], 
validate_form: ValidateJwtForm
+) -> str:
+    """
+    URL: POST /validate-jwt
+    """
+    try:
+        _require_debug_and_allow_tests()
+    except base.ASFQuartException:
+        return quart.abort(404)
+
     token = validate_form.token
     result: dict[str, Any] = {"token_length": len(token), "valid": False}
 
@@ -1044,7 +1207,7 @@ async def _check_keys(fix: bool = False) -> str:
     return message
 
 
-async def _data(session: web.Committer, model: str = "Committee") -> str:
+async def _data_browse(_session: web.Committer, model: str = "Committee") -> 
str:
     """Browse all records in the database."""
     async with db.session() as data:
         # Map of model names to their classes
@@ -1175,8 +1338,8 @@ async def 
_get_filesystem_dirs_unfinished(filesystem_dirs: list[str]) -> None:
                         filesystem_dirs.append(version_dir_path)
 
 
-async def _ongoing_tasks(
-    session: web.Committer,
+async def _fetch_ongoing_tasks(
+    _session: web.Committer,
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     revision: safe.RevisionNumber,
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index a6f93ae9..5ef56003 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -14,23 +14,27 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
 import json
+import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
-from typing import Any
+from typing import Any, Concatenate, overload
 
 import asfquart.base as base
 import asfquart.session
 import pydantic
 import quart
+import quart_schema
 
+import atr.blueprints.common as common
 import atr.form
+import atr.log as log
 import atr.user as user
 import atr.web as web
 
 _BLUEPRINT_NAME = "admin_blueprint"
 _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__, url_prefix="/admin", 
template_folder="../admin/templates")
+_routes: list[str] = []
 
 
 def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., 
Awaitable[Any]]]:
@@ -131,7 +135,125 @@ def register(app: base.QuartApp) -> tuple[ModuleType, 
list[str]]:
     import atr.admin as admin
 
     app.register_blueprint(_BLUEPRINT)
-    return admin, []
+    return admin, _routes
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) 
-> web.RouteFunction[R]: ...
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Public, P], Awaitable[R]]) -> 
web.RouteFunction[R]: ...  # pyright: ignore[reportOverlappingOverload]
+
+
+def typed(func: Callable[..., Any]) -> Callable[..., Any]:
+    """Decorator that derives the URL path from the function's type 
annotations.
+
+    - Literal["..."] parameters become literal path segments
+    - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
+    - pydantic.BaseModel subclass parameters are parsed from the JSON request 
body
+    - dataclass parameters are parsed from the query string
+    - str | None parameters create optional URL segments (two routes 
registered)
+    - int, float, str use Quart's built-in type converters
+    - HTTP method is POST if a body param is present, GET otherwise
+    """
+    path, validated_params, literal_params, body_param, form_param, 
query_param, optional_params = (
+        common.build_api_path(func)
+    )
+    method = "POST" if (body_param is not None or form_param is not None) else 
"GET"
+    body_safe_params = common.safe_params_for_type(body_param[1]) if 
body_param is not None else []
+    form_safe_params = common.safe_params_for_type(form_param[1]) if 
form_param is not None else []
+    query_safe_params = common.safe_params_for_type(query_param[1]) if 
query_param is not None else []
+
+    async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+        enhanced_session = await common.authenticate()
+        await common.validate_params(kwargs, validated_params)
+        kwargs.update(literal_params)
+
+        if body_param is not None:
+            await common.parse_body(body_param, body_safe_params, kwargs)
+
+        if form_param is not None:
+            form_param_name, form_cls = form_param
+            context: dict[str, Any] = {"kwargs": kwargs, "session": 
enhanced_session}
+            try:
+                kwargs[form_param_name] = await 
enhanced_session.form_validate(form_cls, context)
+            except pydantic.ValidationError as e:
+                errors = e.errors()
+                if len(errors) == 0:
+                    raise RuntimeError("Validation failed, but no errors were 
reported")
+                form_data_raw = await atr.form.quart_request()
+                flash_data = atr.form.flash_error_data(form_cls, errors, 
form_data_raw)
+                summary = atr.form.flash_error_summary(errors, flash_data)
+                await quart.flash(summary, category="error")
+                await quart.flash(json.dumps(flash_data), 
category="form-error-data")
+                return quart.redirect(quart.request.path)
+            if form_safe_params:
+                await common.validate_safe_fields(kwargs[form_param_name], 
form_safe_params, kwargs)
+
+        if query_param is not None:
+            await common.parse_query(query_param, query_safe_params, kwargs)
+
+        start_time_ns = time.perf_counter_ns()
+        response = await func(enhanced_session, **kwargs)
+        end_time_ns = time.perf_counter_ns()
+        total_ms = (end_time_ns - start_time_ns) // 1_000_000
+        log.performance(f"API {method} {path} {func.__name__} = 0 0 
{total_ms}")
+
+        return response
+
+    endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+    # Replace the original quart request decorators
+    if query_param is not None:
+        wrapper = quart_schema.validate_querystring(query_param[1])(wrapper)
+    if body_param is not None:
+        wrapper = quart_schema.validate_request(body_param[1])(wrapper)
+
+    # Examine `func` for quart attributes and re-attach to the wrapped function
+    # This makes sure the OpenAPI documentation is preserved
+    # Note: we don't update querystring or request as they're processed above 
using our detected types
+    _copy_quart_attributes(func, wrapper)
+
+    # If there are optional params, we need two routes, one with the optional 
params omitted
+    # and one with them all present.
+    # AM 26/03/03: This actually only handles the case where there's some 
required and a single optional, but
+    # that's the only case that existed in the original code. Theoretically we 
could count the optional params and
+    # generate the correct number of routes, but that's lot of effort for 
little gain right now
+    _add_url_rules(wrapper, path, endpoint, method, optional_params)
+
+    common.register_route(func, "api", _routes)
+    return wrapper
+
+
+def _add_url_rules(
+    wrapper: Callable[..., Any],
+    path: str,
+    endpoint: str,
+    method: str,
+    optional_params: list[str],
+) -> None:
+    """Register URL rules for the wrapper, handling optional path params with 
a default short route."""
+    if optional_params:
+        required_segments = [
+            seg for seg in path.strip("/").split("/") if not any(seg == 
f"<{name}>" for name in optional_params)
+        ]
+        short_path = "/" + "/".join(required_segments)
+        defaults = {name: None for name in optional_params}
+        _BLUEPRINT.add_url_rule(short_path, endpoint=endpoint, 
view_func=wrapper, methods=[method], defaults=defaults)
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint + "_full", 
view_func=wrapper, methods=[method])
+    else:
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=[method])
+
+
+def _copy_quart_attributes(src: Callable[..., Any], dst: Callable[..., Any]) 
-> None:
+    """Copy quart schema attributes from src to dst to preserve OpenAPI 
documentation."""
+    for attr in common.QUART_ATTRIBUTES:
+        if hasattr(src, attr):
+            setattr(dst, attr, getattr(src, attr))
 
 
 @_BLUEPRINT.before_request
diff --git a/atr/blueprints/api.py b/atr/blueprints/api.py
index f04428cb..e335072b 100644
--- a/atr/blueprints/api.py
+++ b/atr/blueprints/api.py
@@ -14,12 +14,11 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import dataclasses
 import datetime
 import inspect
 import sys
 import time
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
 from types import ModuleType
 from typing import Any
 
@@ -33,19 +32,11 @@ import werkzeug.exceptions as exceptions
 
 import atr.blueprints.common as common
 import atr.log as log
+import atr.web as web
 
 _BLUEPRINT_NAME = "api_blueprint"
 _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__, url_prefix="/api")
 _routes: list[str] = []
-_QUART_ATTRIBUTES = [
-    quart_schema.validation.QUART_SCHEMA_HEADERS_ATTRIBUTE,
-    quart_schema.validation.QUART_SCHEMA_RESPONSE_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_SECURITY_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_TAG_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_HIDDEN_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_DEPRECATED_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_OPERATION_ID_ATTRIBUTE,
-]
 
 
 def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
@@ -55,9 +46,10 @@ def register(app: base.QuartApp) -> tuple[ModuleType, 
list[str]]:
     return api, _routes
 
 
-def typed(func: Callable[..., Any]) -> Callable[..., Any]:
+def typed(func: Callable[..., Awaitable[Any]]) -> web.RouteFunction[Any]:
     """Decorator that derives the URL path from the function's type 
annotations.
 
+    - Arguments after session are joined with / to make the web path
     - Literal["..."] parameters become literal path segments
     - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
     - pydantic.BaseModel subclass parameters are parsed from the JSON request 
body
@@ -67,7 +59,9 @@ def typed(func: Callable[..., Any]) -> Callable[..., Any]:
     - HTTP method is POST if a body param is present, GET otherwise
     """
     original = inspect.unwrap(func)
-    path, validated_params, literal_params, body_param, query_param, 
optional_params = common.build_api_path(original)
+    path, validated_params, literal_params, body_param, _, query_param, 
optional_params = common.build_api_path(
+        original
+    )
     method = "POST" if body_param is not None else "GET"
     body_safe_params = common.safe_params_for_type(body_param[1]) if 
body_param is not None else []
     query_safe_params = common.safe_params_for_type(query_param[1]) if 
query_param is not None else []
@@ -77,22 +71,10 @@ def typed(func: Callable[..., Any]) -> Callable[..., Any]:
         kwargs.update(literal_params)
 
         if body_param is not None:
-            body_name, body_cls = body_param
-            json_data = await quart.request.get_json()
-            try:
-                body_instance = body_cls.model_validate(json_data)
-            except pydantic.ValidationError as e:
-                raise quart_schema.RequestSchemaValidationError(e) from e
-            if body_safe_params:
-                await common.validate_safe_fields(body_instance, 
body_safe_params, kwargs)
-            kwargs[body_name] = body_instance
+            await common.parse_body(body_param, body_safe_params, kwargs)
 
         if query_param is not None:
-            query_name, query_cls = query_param
-            query_instance = _parse_query_args(query_cls, quart.request.args)
-            if query_safe_params:
-                await common.validate_safe_fields(query_instance, 
query_safe_params, kwargs)
-            kwargs[query_name] = query_instance
+            await common.parse_query(query_param, query_safe_params, kwargs)
 
         start_time_ns = time.perf_counter_ns()
         response = await func(**kwargs)
@@ -157,7 +139,7 @@ def _add_url_rules(
 
 def _copy_quart_attributes(src: Callable[..., Any], dst: Callable[..., Any]) 
-> None:
     """Copy quart schema attributes from src to dst to preserve OpenAPI 
documentation."""
-    for attr in _QUART_ATTRIBUTES:
+    for attr in common.QUART_ATTRIBUTES:
         if hasattr(src, attr):
             setattr(dst, attr, getattr(src, attr))
 
@@ -197,35 +179,6 @@ async def _handle_request_validation(err: 
quart_schema.RequestSchemaValidationEr
     return _json_error("Input validation failed", 400, {"validation_details": 
verr.errors()})
 
 
-def _coerce_query_field(raw: str, field_type: Any, field_name: str) -> Any:
-    """Coerce a raw query string value to the expected field type."""
-    if field_type is str or field_type == "str":
-        return raw
-    if field_type is int or field_type == "int":
-        try:
-            return int(raw)
-        except ValueError:
-            raise exceptions.BadRequest(f"Query parameter {field_name!r} must 
be an integer")
-    if field_type is bool or field_type == "bool":
-        return raw.lower() in ("true", "1", "yes")
-    return raw
-
-
-def _parse_query_args(query_cls: type, args: Any) -> Any:
-    """Parse query string parameters into a dataclass instance."""
-    field_values: dict[str, Any] = {}
-    for field in dataclasses.fields(query_cls):
-        raw = args.get(field.name)
-        if raw is None:
-            if field.default is not dataclasses.MISSING:
-                field_values[field.name] = field.default
-            elif field.default_factory is not dataclasses.MISSING:
-                field_values[field.name] = field.default_factory()
-            continue
-        field_values[field.name] = _coerce_query_field(raw, field.type, 
field.name)
-    return query_cls(**field_values)
-
-
 def _json_error(
     message: str, status_code: int | None, extra: dict[str, Any] | None = None
 ) -> tuple[quart.Response, int]:
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
index ee69be5a..8457a9ab 100644
--- a/atr/blueprints/common.py
+++ b/atr/blueprints/common.py
@@ -25,6 +25,9 @@ from typing import Annotated, Any, Literal, TypeAliasType, 
get_args, get_origin,
 import asfquart.base as base
 import asfquart.session
 import pydantic
+import quart
+import quart_schema
+import werkzeug.exceptions as exceptions
 
 import atr.form as form
 import atr.ldap as ldap
@@ -89,7 +92,7 @@ def build_path(
     segments: list[str] = []
     validated_params: list[tuple[str, type]] = []
     literal_params: dict[str, str] = {}
-    form_param: tuple[str, type] | None = None
+    unique = _UniqueParams()
 
     for ix, param_name in enumerate(params):
         hint = hints.get(param_name)
@@ -102,16 +105,13 @@ def build_path(
             public = hint is web.Public
             continue
 
-        if _is_form_type(hint):
-            if form_param is not None:
-                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one Form is allowed")
-            form_param = (param_name, hint)
+        if unique.check(hint, param_name, func.__name__):
             continue
 
         _classify_url_param(param_name, hint, func.__name__, segments, 
validated_params, literal_params)
 
     path = "/" + "/".join(segments)
-    return path, validated_params, literal_params, form_param, public
+    return path, validated_params, literal_params, unique.form, public
 
 
 def build_api_path(
@@ -122,6 +122,7 @@ def build_api_path(
     dict[str, str],
     tuple[str, type[pydantic.BaseModel]] | None,
     tuple[str, type] | None,
+    tuple[str, type] | None,
     list[str],
 ]:
     """Inspect a function's type hints to build a URL path for an API route.
@@ -144,25 +145,20 @@ def build_api_path(
     segments: list[str] = []
     validated_params: list[tuple[str, type]] = []
     literal_params: dict[str, str] = {}
-    body_param: tuple[str, type[pydantic.BaseModel]] | None = None
-    query_param: tuple[str, type] | None = None
+    unique = _UniqueParams()
     optional_params: list[str] = []
 
-    for param_name in params:
+    for ix, param_name in enumerate(params):
         hint = hints.get(param_name)
         if hint is None:
             raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
no type annotation")
 
-        if _is_body_type(hint):
-            if body_param is not None:
-                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one body type is allowed")
-            body_param = (param_name, hint)
+        if (hint is web.Public) or (hint is web.Committer):
+            if ix != 0:
+                raise TypeError(f"Parameter {param_name!r} in {func.__name__} 
must be first")
             continue
 
-        if _is_query_type(hint):
-            if query_param is not None:
-                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one query type is allowed")
-            query_param = (param_name, hint)
+        if unique.check(hint, param_name, func.__name__):
             continue
 
         inner, is_optional = _unwrap_optional(hint)
@@ -175,7 +171,7 @@ def build_api_path(
         _classify_url_param(param_name, hint, func.__name__, segments, 
validated_params, literal_params)
 
     path = "/" + "/".join(segments)
-    return path, validated_params, literal_params, body_param, query_param, 
optional_params
+    return path, validated_params, literal_params, unique.body, unique.form, 
unique.query, optional_params
 
 
 def register_route(func: Callable[..., Any], prefix: str, routes: list[str]) 
-> None:
@@ -236,6 +232,96 @@ async def validate_safe_fields(
             setattr(instance, name, temp[name])
 
 
+async def parse_body(
+    body_param: tuple[str, type[pydantic.BaseModel]],
+    safe_params: list[tuple[str, type]],
+    kwargs: dict[str, Any],
+) -> None:
+    """Parse and validate a JSON body parameter, adding it to kwargs."""
+    body_name, body_cls = body_param
+    json_data = await quart.request.get_json()
+    try:
+        body_instance = body_cls.model_validate(json_data)
+    except pydantic.ValidationError as e:
+        raise quart_schema.RequestSchemaValidationError(e) from e
+    if safe_params:
+        await validate_safe_fields(body_instance, safe_params, kwargs)
+    kwargs[body_name] = body_instance
+
+
+async def parse_query(
+    query_param: tuple[str, type],
+    safe_params: list[tuple[str, type]],
+    kwargs: dict[str, Any],
+) -> None:
+    """Parse and validate query string parameters, adding them to kwargs."""
+    query_name, query_cls = query_param
+    query_instance = _parse_query_args(query_cls, quart.request.args)
+    if safe_params:
+        await validate_safe_fields(query_instance, safe_params, kwargs)
+    kwargs[query_name] = query_instance
+
+
+def _coerce_query_field(raw: str, field_type: Any, field_name: str) -> Any:
+    """Coerce a raw query string value to the expected field type."""
+    if field_type is str or field_type == "str":
+        return raw
+    if field_type is int or field_type == "int":
+        try:
+            return int(raw)
+        except ValueError:
+            raise exceptions.BadRequest(f"Query parameter {field_name!r} must 
be an integer")
+    if field_type is bool or field_type == "bool":
+        return raw.lower() in ("true", "1", "yes")
+    return raw
+
+
+def _parse_query_args(query_cls: type, args: Any) -> Any:
+    """Parse query string parameters into a dataclass instance."""
+    field_values: dict[str, Any] = {}
+    for field in dataclasses.fields(query_cls):
+        raw = args.get(field.name)
+        if raw is None:
+            if field.default is not dataclasses.MISSING:
+                field_values[field.name] = field.default
+            elif field.default_factory is not dataclasses.MISSING:
+                field_values[field.name] = field.default_factory()
+            continue
+        field_values[field.name] = _coerce_query_field(raw, field.type, 
field.name)
+    return query_cls(**field_values)
+
+
[email protected]
+class _UniqueParams:
+    """Tracks the at-most-one body, form, and query parameters during path 
building."""
+
+    body: tuple[str, type[pydantic.BaseModel]] | None = None
+    form: tuple[str, type] | None = None
+    query: tuple[str, type] | None = None
+
+    def check(self, hint: Any, param_name: str, func_name: str) -> bool:
+        """If hint is a body/form/query type, store it (ensuring uniqueness). 
Return True if matched."""
+        if _is_body_type(hint):
+            if self.body is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func_name}: 
only one body type is allowed")
+            self.body = (param_name, hint)
+            return True
+
+        if _is_form_type(hint):
+            if self.form is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func_name}: 
only one Form is allowed")
+            self.form = (param_name, hint)
+            return True
+
+        if _is_query_type(hint):
+            if self.query is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func_name}: 
only one query type is allowed")
+            self.query = (param_name, hint)
+            return True
+
+        return False
+
+
 def _classify_url_param(
     param_name: str,
     hint: Any,
@@ -309,3 +395,14 @@ def _unwrap_optional(hint: Any) -> tuple[Any, bool]:
     if len(non_none) == 1 and type(None) in args:
         return non_none[0], True
     return hint, False
+
+
+QUART_ATTRIBUTES = [
+    quart_schema.validation.QUART_SCHEMA_HEADERS_ATTRIBUTE,
+    quart_schema.validation.QUART_SCHEMA_RESPONSE_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_SECURITY_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_TAG_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_HIDDEN_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_DEPRECATED_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_OPERATION_ID_ATTRIBUTE,
+]
diff --git a/atr/get/test.py b/atr/get/test.py
index d5d98521..f1ccba41 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -21,6 +21,7 @@ from typing import Literal
 
 import aiofiles
 import asfquart.base as base
+import quart
 import werkzeug.wrappers.response as response
 
 import atr.blueprints.get as get
@@ -68,7 +69,7 @@ async def test_login(_session: web.Public, _test_login: 
Literal["test/login"]) -
     URL: /test/login
     """
     if not config.get().ALLOW_TESTS:
-        return await web.redirect(root.notfound)
+        return quart.abort(404)
 
     session_data = atr.models.session.CookieData(
         uid="test",
@@ -96,7 +97,7 @@ async def test_merge(
     URL: /test/merge/<project_key>/<version_key>
     """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test routes not enabled", errorcode=404)
+        return quart.abort(404)
 
     async with storage.write(session) as write_n:
         wacp_n = await write_n.as_project_committee_participant(project_key)
@@ -213,7 +214,7 @@ async def test_vote(
     URL: /test/vote/<category>/<project_key>/<version_key>
     """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test routes not enabled", errorcode=404)
+        return quart.abort(404)
 
     category_map = {
         "unauthenticated": vote.UserCategory.UNAUTHENTICATED,
diff --git a/tests/unit/test_blueprints.py b/tests/unit/test_blueprints.py
new file mode 100644
index 00000000..af268bfe
--- /dev/null
+++ b/tests/unit/test_blueprints.py
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import asfquart
+import pytest
+
+import atr.blueprints as blueprints
+import atr.util as util
+
+_TESTED_BLUEPRINTS = frozenset({"get_blueprint", "post_blueprint", 
"admin_blueprint"})
+
+
[email protected]
+async def test_all_routes_support_url_construction(monkeypatch):
+    # Prevent writing routes.json to a real directory
+    monkeypatch.setattr("atr.blueprints._export_routes", lambda _: None)
+    # We don't need a functional .APP for this test but we do need it reset 
afterwards
+    monkeypatch.setattr("asfquart.APP", None)
+
+    app = asfquart.construct("test")
+    blueprints.register(app)
+
+    failures: list[str] = []
+
+    async with app.test_request_context("/"):
+        for rule in app.url_map.iter_rules():
+            blueprint_name = rule.endpoint.rsplit(".", 1)[0] if "." in 
rule.endpoint else None
+            if blueprint_name not in _TESTED_BLUEPRINTS:
+                continue
+
+            view_func = app.view_functions[rule.endpoint]
+
+            # Build dummy kwargs so url_for can construct the URL
+            kwargs = {}
+            for arg in rule.arguments:
+                converter = rule._converters.get(arg)
+                if converter is not None and type(converter).__name__ == 
"IntegerConverter":
+                    kwargs[arg] = 1
+                else:
+                    kwargs[arg] = "test"
+
+            try:
+                util.as_url(view_func, **kwargs)
+            except Exception as e:
+                failures.append(f"{rule.endpoint} ({rule.rule}): {e}")
+
+    if failures:
+        raise AssertionError("Routes incompatible with as_url:\n" + 
"\n".join(failures))


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to