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 <[email protected]>
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
[email protected]("/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
[email protected]("/releases/<project>")
-@quart_schema.validate_querystring(models.api.Pagination)
-async def releases_project(project: str, query_args: models.api.Pagination) ->
quart.Response:
[email protected]("/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
[email protected]("/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]:
[email protected]("/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>
[email protected]("/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]:
[email protected]("/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
[email protected]("/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?
[email protected]
-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"]
[email protected]
+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
+
+
[email protected]
+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: [email protected]
For additional commands, e-mail: [email protected]