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 fe88b30  Use consistent types for the API release endpoints
fe88b30 is described below

commit fe88b304ef73fb6fb4df579c758f9fc70c241ab5
Author: Sean B. Palmer <s...@miscoranda.com>
AuthorDate: Tue Jul 15 17:29:30 2025 +0100

    Use consistent types for the API release endpoints
---
 atr/blueprints/api/api.py | 115 +++++++++++++++++++++++++++-------------------
 atr/models/api.py         |  97 +++++++++++++++++++++++++++++++++++---
 atr/models/sql.py         |  10 ++++
 3 files changed, 169 insertions(+), 53 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 5cc4a7d..c4a1f9c 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -316,6 +316,31 @@ async def keys_ssh_add(data: models.api.KeysSshAddArgs) -> 
DictResponse:
     ).model_dump(), 201
 
 
+@api.BLUEPRINT.route("/keys/ssh/list")
+@quart_schema.validate_querystring(models.api.KeysSshListQuery)
+async def keys_ssh_list(query_args: models.api.KeysSshListQuery) -> 
DictResponse:
+    """Paged list of developer SSH public keys."""
+    _pagination_args_validate(query_args)
+    via = sql.validate_instrumented_attribute
+    async with db.session() as data:
+        statement = (
+            sqlmodel.select(sql.SSHKey)
+            .limit(query_args.limit)
+            .offset(query_args.offset)
+            .order_by(via(sql.SSHKey.fingerprint).asc())
+        )
+        paged_keys = (await data.execute(statement)).scalars().all()
+
+        count_stmt = 
sqlalchemy.select(sqlalchemy.func.count(via(sql.SSHKey.fingerprint)))
+        count = (await data.execute(count_stmt)).scalar_one()
+
+        return models.api.KeysSshListResults(
+            endpoint="/keys/ssh/list",
+            data=paged_keys,
+            count=count,
+        ).model_dump(), 200
+
+
 # TODO: Call this release/paths
 @api.BLUEPRINT.route("/list/<project>/<version>")
 @api.BLUEPRINT.route("/list/<project>/<version>/<revision>")
@@ -379,8 +404,9 @@ async def projects() -> DictResponse:
 
 
 @api.BLUEPRINT.route("/releases")
-@quart_schema.validate_querystring(models.api.Releases)
-async def releases(query_args: models.api.Releases) -> quart.Response:
+@quart_schema.validate_querystring(models.api.ReleasesQuery)
+@quart_schema.validate_response(models.api.ReleasesResults, 200)
+async def releases(query_args: models.api.ReleasesQuery) -> DictResponse:
     """Paged list of releases with optional filtering by phase."""
     _pagination_args_validate(query_args)
     via = sql.validate_instrumented_attribute
@@ -408,16 +434,19 @@ async def releases(query_args: models.api.Releases) -> 
quart.Response:
 
         count = (await data.execute(count_stmt)).scalar_one()
 
-        result = {"data": [release.model_dump() for release in 
paged_releases], "count": count}
-        return quart.jsonify(result)
+        return models.api.ReleasesResults(
+            endpoint="/releases",
+            data=paged_releases,
+            count=count,
+        ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/releases/create", methods=["POST"])
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
-@quart_schema.validate_request(models.api.ProjectVersion)
-@quart_schema.validate_response(sql.Release, 201)
-async def releases_create(data: models.api.ProjectVersion) -> tuple[Mapping, 
int]:
+@quart_schema.validate_request(models.api.ReleasesCreateArgs)
+@quart_schema.validate_response(models.api.ReleasesCreateResults, 201)
+async def releases_create(data: models.api.ReleasesCreateArgs) -> DictResponse:
     """Create a new release draft for a project via POSTed JSON."""
     asf_uid = _jwt_asf_uid()
 
@@ -430,15 +459,18 @@ async def releases_create(data: 
models.api.ProjectVersion) -> tuple[Mapping, int
     except routes.FlashError as exc:
         raise exceptions.BadRequest(str(exc))
 
-    return release.model_dump(), 201
+    return models.api.ReleasesCreateResults(
+        endpoint="/releases/create",
+        release=release,
+    ).model_dump(), 201
 
 
 @api.BLUEPRINT.route("/releases/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 releases_delete(data: models.api.ProjectVersion) -> tuple[Mapping, 
int]:
+@quart_schema.validate_request(models.api.ReleasesDeleteArgs)
+@quart_schema.validate_response(models.api.ReleasesDeleteResults, 200)
+async def releases_delete(data: models.api.ReleasesDeleteArgs) -> DictResponse:
     """Delete a release draft for a project via POSTed JSON."""
     asf_uid = _jwt_asf_uid()
     if not user.is_admin(asf_uid):
@@ -448,12 +480,15 @@ async def releases_delete(data: 
models.api.ProjectVersion) -> tuple[Mapping, int
         release_name = sql.release_name(data.project, data.version)
         await interaction.release_delete(release_name, include_downloads=True)
         await db_data.commit()
-    return {"deleted": release_name}, 200
+    return models.api.ReleasesDeleteResults(
+        endpoint="/releases/delete",
+        deleted=release_name,
+    ).model_dump(), 200
 
 
-@api.BLUEPRINT.route("/releases/<project>")
-@quart_schema.validate_querystring(models.api.Pagination)
-async def releases_project(project: str, query_args: models.api.Pagination) -> 
quart.Response:
+@api.BLUEPRINT.route("/releases/project/<project>")
+@quart_schema.validate_querystring(models.api.ReleasesProjectQuery)
+async def releases_project(project: str, query_args: 
models.api.ReleasesProjectQuery) -> DictResponse:
     """List all releases for a specific project with pagination."""
     _simple_check(project)
     _pagination_args_validate(query_args)
@@ -478,32 +513,42 @@ async def releases_project(project: str, query_args: 
models.api.Pagination) -> q
         )
         count = (await data.execute(count_stmt)).scalar_one()
 
-        result = {"data": [release.model_dump() for release in 
paged_releases], "count": count}
-        return quart.jsonify(result)
+        return models.api.ReleasesProjectResults(
+            endpoint="/releases/project",
+            data=paged_releases,
+            count=count,
+        ).model_dump(), 200
 
 
-@api.BLUEPRINT.route("/releases/<project>/<version>")
 # TODO: If we validate as sql.Release, quart_schema silently corrupts 
latest_revision_number to None
 # @quart_schema.validate_response(sql.Release, 200)
-async def releases_project_version(project: str, version: str) -> 
tuple[Mapping, int]:
+@api.BLUEPRINT.route("/releases/version/<project>/<version>")
+@quart_schema.validate_response(models.api.ReleasesVersionResults, 200)
+async def releases_project_version(project: str, version: str) -> DictResponse:
     """Return a single release by project and version."""
     _simple_check(project, version)
     async with db.session() as data:
         release_name = sql.release_name(project, version)
         release = await 
data.release(name=release_name).demand(exceptions.NotFound())
-        return release.model_dump(), 200
+        return models.api.ReleasesVersionResults(
+            endpoint="/releases/version",
+            release=release,
+        ).model_dump(), 200
 
 
 # TODO: Rename this to revisions? I.e. /revisions/<project>/<version>
-@api.BLUEPRINT.route("/releases/<project>/<version>/revisions")
-@quart_schema.validate_response(list[sql.Revision], 200)
-async def releases_project_version_revisions(project: str, version: str) -> 
tuple[list[Mapping], int]:
+@api.BLUEPRINT.route("/releases/revisions/<project>/<version>")
+@quart_schema.validate_response(models.api.ReleasesRevisionsResults, 200)
+async def releases_project_version_revisions(project: str, version: str) -> 
DictResponse:
     """List all revisions for a given release."""
     _simple_check(project, version)
     async with db.session() as data:
         release_name = sql.release_name(project, version)
         revisions = await data.revision(release_name=release_name).all()
-        return [rev.model_dump() for rev in revisions], 200
+        return models.api.ReleasesRevisionsResults(
+            endpoint="/releases/revisions",
+            revisions=revisions,
+        ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/revisions/<project>/<version>")
@@ -529,28 +574,6 @@ async def revisions_project_version(project: str, version: 
str) -> tuple[dict[st
 #     return {"secret": "*******"}, 200
 
 
-@api.BLUEPRINT.route("/ssh-keys")
-@quart_schema.validate_querystring(models.api.Pagination)
-async def ssh_keys(query_args: models.api.Pagination) -> quart.Response:
-    """Paged list of developer SSH public keys."""
-    _pagination_args_validate(query_args)
-    via = sql.validate_instrumented_attribute
-    async with db.session() as data:
-        statement = (
-            sqlmodel.select(sql.SSHKey)
-            .limit(query_args.limit)
-            .offset(query_args.offset)
-            .order_by(via(sql.SSHKey.fingerprint).asc())
-        )
-        paged_keys = (await data.execute(statement)).scalars().all()
-
-        count_stmt = 
sqlalchemy.select(sqlalchemy.func.count(via(sql.SSHKey.fingerprint)))
-        count = (await data.execute(count_stmt)).scalar_one()
-
-        result = {"data": [key.model_dump() for key in paged_keys], "count": 
count}
-        return quart.jsonify(result)
-
-
 @api.BLUEPRINT.route("/tasks")
 @quart_schema.validate_querystring(models.api.Task)
 async def tasks(query_args: models.api.Task) -> quart.Response:
diff --git a/atr/models/api.py b/atr/models/api.py
index 9a0539f..4311afc 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -36,12 +36,6 @@ class Pagination:
     limit: int = 20
 
 
-# TODO: ReleasesPagination?
-@dataclasses.dataclass
-class Releases(Pagination):
-    phase: str | None = None
-
-
 # TODO: TaskPagination?
 @dataclasses.dataclass
 class Task(Pagination):
@@ -145,6 +139,17 @@ class KeysSshAddResults(schema.Strict):
     fingerprint: str
 
 
+class KeysSshListQuery(Pagination):
+    offset: int = 0
+    limit: int = 20
+
+
+class KeysSshListResults(schema.Strict):
+    endpoint: Literal["/keys/ssh/list"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.SSHKey]
+    count: int
+
+
 class ProjectResults(schema.Strict):
     endpoint: Literal["/project"] = schema.Field(alias="endpoint")
     project: sql.Project
@@ -178,6 +183,68 @@ class ProjectVersionResolution(schema.Strict):
     resolution: Literal["passed", "failed"]
 
 
+@dataclasses.dataclass
+class ReleasesQuery:
+    offset: int = 0
+    limit: int = 20
+    phase: str | None = None
+
+
+class ReleasesResults(schema.Strict):
+    endpoint: Literal["/releases"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.Release]
+    count: int
+
+
+class ReleasesCreateArgs(schema.Strict):
+    project: str
+    version: str
+
+
+class ReleasesCreateResults(schema.Strict):
+    endpoint: Literal["/releases/create"] = schema.Field(alias="endpoint")
+    release: sql.Release
+
+
+class ReleasesDeleteArgs(schema.Strict):
+    project: str
+    version: str
+
+
+class ReleasesDeleteResults(schema.Strict):
+    endpoint: Literal["/releases/delete"] = schema.Field(alias="endpoint")
+    deleted: str
+
+
+@dataclasses.dataclass
+class ReleasesProjectQuery:
+    limit: int = 20
+    offset: int = 0
+    # project: str
+    # version: str
+
+
+class ReleasesProjectResults(schema.Strict):
+    endpoint: Literal["/releases/project"] = schema.Field(alias="endpoint")
+    data: Sequence[sql.Release]
+    count: int
+
+    @pydantic.field_validator("data", mode="before")
+    @classmethod
+    def coerce_release(cls, v: Sequence[dict[str, Any]]) -> 
Sequence[sql.Release]:
+        return [sql.Release.model_validate(item) if isinstance(item, dict) 
else item for item in v]
+
+
+class ReleasesVersionResults(schema.Strict):
+    endpoint: Literal["/releases/version"] = schema.Field(alias="endpoint")
+    release: sql.Release
+
+
+class ReleasesRevisionsResults(schema.Strict):
+    endpoint: Literal["/releases/revisions"] = schema.Field(alias="endpoint")
+    revisions: Sequence[sql.Revision]
+
+
 class Text(schema.Strict):
     text: str
 
@@ -192,6 +259,8 @@ class VoteStart(schema.Strict):
     body: str
 
 
+# This is for *Results classes only
+# We do NOT put *Args classes here
 Results = Annotated[
     AnnounceResults
     | ChecksListResults
@@ -205,10 +274,17 @@ Results = Annotated[
     | KeyResults
     | KeysResults
     | KeysSshAddResults
+    | KeysSshListResults
     | ListResults
     | ProjectResults
     | ProjectReleasesResults
-    | ProjectsResults,
+    | ProjectsResults
+    | ReleasesResults
+    | ReleasesCreateResults
+    | ReleasesDeleteResults
+    | ReleasesProjectResults
+    | ReleasesVersionResults
+    | ReleasesRevisionsResults,
     schema.Field(discriminator="endpoint"),
 ]
 
@@ -237,7 +313,14 @@ validate_jwt = validator(JwtResults)
 validate_key = validator(KeyResults)
 validate_keys = validator(KeysResults)
 validate_keys_ssh_add = validator(KeysSshAddResults)
+validate_keys_ssh_list = validator(KeysSshListResults)
 validate_list = validator(ListResults)
 validate_project = validator(ProjectResults)
 validate_project_releases = validator(ProjectReleasesResults)
 validate_projects = validator(ProjectsResults)
+validate_releases = validator(ReleasesResults)
+validate_releases_create = validator(ReleasesCreateResults)
+validate_releases_delete = validator(ReleasesDeleteResults)
+validate_releases_project = validator(ReleasesProjectResults)
+validate_releases_version = validator(ReleasesVersionResults)
+validate_releases_revisions = validator(ReleasesRevisionsResults)
diff --git a/atr/models/sql.py b/atr/models/sql.py
index dea5604..d2c91b2 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -597,6 +597,16 @@ class Release(sqlmodel.SQLModel, table=True):
             raise ValueError("Latest revision number is not a str or None")
         return number
 
+    @pydantic.field_validator("created", mode="before")
+    @classmethod
+    def parse_created(cls, v: str | datetime.datetime):
+        return datetime.datetime.fromisoformat(v.rstrip("Z")) if isinstance(v, 
str) else v
+
+    @pydantic.field_validator("phase", mode="before")
+    @classmethod
+    def parse_phase(cls, v: str | ReleasePhase):
+        return ReleasePhase(v) if isinstance(v, str) else v
+
     # NOTE: This does not work
     # But it we set it with Release.latest_revision_number_query = ..., it 
might work
     # Not clear that we'd want to do that, though


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscr...@tooling.apache.org
For additional commands, e-mail: commits-h...@tooling.apache.org

Reply via email to