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

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


The following commit(s) were added to refs/heads/main by this push:
     new 7027ee6  Use consistent types for API endpoints from draft deletion to 
projects
7027ee6 is described below

commit 7027ee652391bbece22ec2a1a0eea698e819e2fd
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 15 16:31:02 2025 +0100

    Use consistent types for API endpoints from draft deletion to projects
---
 atr/blueprints/api/api.py | 157 +++++++++++++++++++++++++++-------------------
 atr/models/api.py         |  81 ++++++++++++++++++++++--
 2 files changed, 168 insertions(+), 70 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 0ad1faf..5cc4a7d 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -213,9 +213,9 @@ async def committees_projects(name: str) -> DictResponse:
 @api.BLUEPRINT.route("/draft/delete", methods=["POST"])
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.ProjectVersion)
-@quart_schema.validate_response(dict[str, str], 200)
-async def draft_delete_project_version(data: models.api.ProjectVersion) -> 
tuple[dict[str, str], int]:
+@quart_schema.validate_request(models.api.DraftDeleteArgs)
+@quart_schema.validate_response(models.api.DraftDeleteResults, 200)
+async def draft_delete(data: models.api.DraftDeleteArgs) -> DictResponse:
     asf_uid = _jwt_asf_uid()
 
     async with db.session() as db_data:
@@ -236,35 +236,15 @@ async def draft_delete_project_version(data: 
models.api.ProjectVersion) -> tuple
             release_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, 
include_downloads=False
         )
         await db_data.commit()
-    return {"deleted": release_name}, 200
-
-
-# TODO: Call this release/paths
[email protected]("/list/<project>/<version>")
[email protected]("/list/<project>/<version>/<revision>")
-@quart_schema.validate_response(dict[str, list[str]], 200)
-async def list_project_version(
-    project: str, version: str, revision: str | None = None
-) -> tuple[dict[str, list[str]], int]:
-    _simple_check(project, version, revision)
-    async with db.session() as data:
-        release_name = sql.release_name(project, version)
-        release = await 
data.release(name=release_name).demand(exceptions.NotFound())
-        if revision is None:
-            dir_path = util.release_directory(release)
-        else:
-            await data.revision(release_name=release_name, 
number=revision).demand(exceptions.NotFound())
-            dir_path = util.release_directory_version(release) / revision
-    if not (await aiofiles.os.path.isdir(dir_path)):
-        raise exceptions.NotFound("Files not found")
-    files: list[str] = [str(path) for path in [p async for p in 
util.paths_recursive(dir_path)]]
-    files.sort()
-    return {"rel_paths": files}, 200
+    return models.api.DraftDeleteResults(
+        endpoint="/draft/delete",
+        success=f"Draft {release_name} deleted",
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/jwt", methods=["POST"])
-@quart_schema.validate_request(models.api.AsfuidPat)
-async def pat_jwt_post(data: models.api.AsfuidPat) -> quart.Response:
+@quart_schema.validate_request(models.api.JwtArgs)
+async def jwt_post(data: models.api.JwtArgs) -> DictResponse:
     """Generate a JWT from a valid PAT."""
     # Expects {"asfuid": "uid", "pat": "pat-token"}
     # Returns {"asfuid": "uid", "jwt": "jwt-token"}
@@ -273,15 +253,33 @@ async def pat_jwt_post(data: models.api.AsfuidPat) -> 
quart.Response:
 
     now = datetime.datetime.now(datetime.UTC)
     if (pat_rec is None) or (pat_rec.expires < now):
-        return quart.Response("Invalid PAT", status=401)
+        raise exceptions.Unauthorized("Invalid PAT")
 
     jwt_token = jwtoken.issue(data.asfuid)
-    return quart.jsonify({"asfuid": data.asfuid, "jwt": jwt_token})
+    return models.api.JwtResults(
+        endpoint="/jwt",
+        asfuid=data.asfuid,
+        jwt=jwt_token,
+    ).model_dump(), 200
+
+
[email protected]("/key/<fingerprint>")
+@quart_schema.validate_response(models.api.KeyResults, 200)
+async def key(fingerprint: str) -> DictResponse:
+    """Return a single public signing key by fingerprint."""
+    _simple_check(fingerprint)
+    async with db.session() as data:
+        key = await 
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
+        return models.api.KeyResults(
+            endpoint="/key",
+            key=key,
+        ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/keys")
-@quart_schema.validate_querystring(models.api.Pagination)
-async def public_keys(query_args: models.api.Pagination) -> quart.Response:
+@quart_schema.validate_querystring(models.api.KeysQuery)
+@quart_schema.validate_response(models.api.KeysResults, 200)
+async def public_keys(query_args: models.api.KeysQuery) -> DictResponse:
     """List all public signing keys with pagination support."""
     _pagination_args_validate(query_args)
     via = sql.validate_instrumented_attribute
@@ -296,61 +294,88 @@ async def public_keys(query_args: models.api.Pagination) 
-> quart.Response:
         count = (
             await 
data.execute(sqlalchemy.select(sqlalchemy.func.count(via(sql.PublicSigningKey.fingerprint))))
         ).scalar_one()
-        result = {"data": [key.model_dump() for key in paged_keys], "count": 
count}
-        return quart.jsonify(result)
-
-
[email protected]("/keys/<fingerprint>")
-@quart_schema.validate_response(sql.PublicSigningKey, 200)
-async def public_keys_fingerprint(fingerprint: str) -> tuple[Mapping, int]:
-    """Return a single public signing key by fingerprint."""
-    _simple_check(fingerprint)
-    async with db.session() as data:
-        key = await 
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
-        return key.model_dump(), 200
+        return models.api.KeysResults(
+            endpoint="/keys",
+            data=paged_keys,
+            count=count,
+        ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/keys/ssh/add", methods=["POST"])
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.Text)
-@quart_schema.validate_response(models.api.Fingerprint, 201)
-async def keys_ssh_add(data: models.api.Text) -> tuple[Mapping, int]:
+@quart_schema.validate_request(models.api.KeysSshAddArgs)
+@quart_schema.validate_response(models.api.KeysSshAddResults, 201)
+async def keys_ssh_add(data: models.api.KeysSshAddArgs) -> DictResponse:
     """Add an SSH key for a user."""
     asf_uid = _jwt_asf_uid()
     fingerprint = await keys.ssh_key_add(data.text, asf_uid)
-    return models.api.Fingerprint(
+    return models.api.KeysSshAddResults(
         endpoint="/keys/ssh/add",
         fingerprint=fingerprint,
     ).model_dump(), 201
 
 
[email protected]("/projects")
-@quart_schema.validate_response(list[sql.Committee], 200)
-async def projects() -> tuple[list[Mapping], int]:
-    """List all projects in the database."""
+# TODO: Call this release/paths
[email protected]("/list/<project>/<version>")
[email protected]("/list/<project>/<version>/<revision>")
+@quart_schema.validate_response(models.api.ListResults, 200)
+async def list_project_version(project: str, version: str, revision: str | 
None = None) -> DictResponse:
+    _simple_check(project, version, revision)
     async with db.session() as data:
-        committees = await data.committee().all()
-        return [committee.model_dump() for committee in committees], 200
+        release_name = sql.release_name(project, version)
+        release = await 
data.release(name=release_name).demand(exceptions.NotFound())
+        if revision is None:
+            dir_path = util.release_directory(release)
+        else:
+            await data.revision(release_name=release_name, 
number=revision).demand(exceptions.NotFound())
+            dir_path = util.release_directory_version(release) / revision
+    if not (await aiofiles.os.path.isdir(dir_path)):
+        raise exceptions.NotFound("Files not found")
+    files: list[str] = [str(path) for path in [p async for p in 
util.paths_recursive(dir_path)]]
+    files.sort()
+    return models.api.ListResults(
+        endpoint="/list",
+        rel_paths=files,
+    ).model_dump(), 200
 
 
[email protected]("/projects/<name>")
-@quart_schema.validate_response(sql.Committee, 200)
-async def projects_name(name: str) -> tuple[Mapping, int]:
[email protected]("/project/<name>")
+@quart_schema.validate_response(models.api.ProjectResults, 200)
+async def project(name: str) -> DictResponse:
     _simple_check(name)
     async with db.session() as data:
-        committee = await 
data.committee(name=name).demand(exceptions.NotFound())
-        return committee.model_dump(), 200
+        project = await data.project(name=name).demand(exceptions.NotFound())
+        return models.api.ProjectResults(
+            endpoint="/project",
+            project=project,
+        ).model_dump(), 200
 
 
[email protected]("/projects/<name>/releases")
-@quart_schema.validate_response(list[sql.Release], 200)
-async def projects_name_releases(name: str) -> tuple[list[Mapping], int]:
[email protected]("/project/releases/<name>")
+@quart_schema.validate_response(models.api.ProjectReleasesResults, 200)
+async def project_releases(name: str) -> DictResponse:
     """List all releases for a specific project."""
     _simple_check(name)
     async with db.session() as data:
         releases = await data.release(project_name=name).all()
-        return [release.model_dump() for release in releases], 200
+        return models.api.ProjectReleasesResults(
+            endpoint="/project/releases",
+            releases=releases,
+        ).model_dump(), 200
+
+
[email protected]("/projects")
+@quart_schema.validate_response(models.api.ProjectsResults, 200)
+async def projects() -> DictResponse:
+    """List all projects in the database."""
+    # TODO: Add pagination?
+    async with db.session() as data:
+        projects = await data.project().all()
+        return models.api.ProjectsResults(
+            endpoint="/projects",
+            projects=projects,
+        ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/releases")
@@ -668,10 +693,10 @@ def _jwt_asf_uid() -> str:
     return asf_uid
 
 
-def _pagination_args_validate(query_args: models.api.Pagination) -> None:
+def _pagination_args_validate(query_args: Any) -> None:
     # Users could request any amount using limit=N with arbitrarily high N
     # We therefore limit the maximum limit to 1000
-    if query_args.limit > 1000:
+    if hasattr(query_args, "limit") and (query_args.limit > 1000):
         # quart.abort(400, "Limit is too high")
         raise exceptions.BadRequest("Maximum limit of 1000 exceeded")
 
diff --git a/atr/models/api.py b/atr/models/api.py
index e67bcf5..9a0539f 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -93,16 +93,73 @@ class CommitteesProjectsResults(schema.Strict):
     projects: Sequence[sql.Project]
 
 
-class AsfuidPat(schema.Strict):
+class DraftDeleteArgs(schema.Strict):
+    project: str
+    version: str
+
+
+class DraftDeleteResults(schema.Strict):
+    endpoint: Literal["/draft/delete"] = schema.Field(alias="endpoint")
+    success: str
+
+
+class ListResults(schema.Strict):
+    endpoint: Literal["/list"] = schema.Field(alias="endpoint")
+    rel_paths: Sequence[str]
+
+
+class JwtArgs(schema.Strict):
     asfuid: str
     pat: str
 
 
-class Fingerprint(schema.Strict):
+class JwtResults(schema.Strict):
+    endpoint: Literal["/jwt"] = schema.Field(alias="endpoint")
+    asfuid: str
+    jwt: str
+
+
+class KeyResults(schema.Strict):
+    endpoint: Literal["/key"] = schema.Field(alias="endpoint")
+    key: sql.PublicSigningKey
+
+
[email protected]
+class KeysQuery:
+    offset: int = 0
+    limit: int = 20
+
+
+class KeysResults(schema.Strict):
+    endpoint: Literal["/keys"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.PublicSigningKey]
+    count: int
+
+
+class KeysSshAddArgs(schema.Strict):
+    text: str
+
+
+class KeysSshAddResults(schema.Strict):
     endpoint: Literal["/keys/ssh/add"] = schema.Field(alias="endpoint")
     fingerprint: str
 
 
+class ProjectResults(schema.Strict):
+    endpoint: Literal["/project"] = schema.Field(alias="endpoint")
+    project: sql.Project
+
+
+class ProjectReleasesResults(schema.Strict):
+    endpoint: Literal["/project/releases"] = schema.Field(alias="endpoint")
+    releases: Sequence[sql.Release]
+
+
+class ProjectsResults(schema.Strict):
+    endpoint: Literal["/projects"] = schema.Field(alias="endpoint")
+    projects: Sequence[sql.Project]
+
+
 class ProjectVersion(schema.Strict):
     project: str
     version: str
@@ -143,7 +200,15 @@ Results = Annotated[
     | CommitteesKeysResults
     | CommitteesListResults
     | CommitteesProjectsResults
-    | Fingerprint,
+    | DraftDeleteResults
+    | JwtResults
+    | KeyResults
+    | KeysResults
+    | KeysSshAddResults
+    | ListResults
+    | ProjectResults
+    | ProjectReleasesResults
+    | ProjectsResults,
     schema.Field(discriminator="endpoint"),
 ]
 
@@ -167,4 +232,12 @@ validate_committees = validator(CommitteesResults)
 validate_committees_keys = validator(CommitteesKeysResults)
 validate_committees_list = validator(CommitteesListResults)
 validate_committees_projects = validator(CommitteesProjectsResults)
-validate_fingerprint = validator(Fingerprint)
+validate_draft_delete = validator(DraftDeleteResults)
+validate_jwt = validator(JwtResults)
+validate_key = validator(KeyResults)
+validate_keys = validator(KeysResults)
+validate_keys_ssh_add = validator(KeysSshAddResults)
+validate_list = validator(ListResults)
+validate_project = validator(ProjectResults)
+validate_project_releases = validator(ProjectReleasesResults)
+validate_projects = validator(ProjectsResults)


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

Reply via email to