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