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 d28b141  Add documentation for keys and other API endpoints
d28b141 is described below

commit d28b141024561aad32fc068c6a8beefee75754c8
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Jul 28 20:39:49 2025 +0100

    Add documentation for keys and other API endpoints
---
 atr/blueprints/api/api.py |  32 ++++++++++----
 atr/db/interaction.py     | 104 +++++++++++++++++++++++-----------------------
 atr/models/api.py         |  60 +++++++++++++-------------
 3 files changed, 108 insertions(+), 88 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 40d9a53..93a174c 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -305,6 +305,11 @@ async def committees_projects(name: str) -> DictResponse:
 async def draft_delete(data: models.api.DraftDeleteArgs) -> DictResponse:
     """
     Delete a draft release.
+
+    The draft release is deleted, and all of its associated metadata and files
+    are removed from the database and the filesystem. This cannot be undone.
+
+    Warning: we plan to change how draft deletion works.
     """
     asf_uid = _jwt_asf_uid()
 
@@ -312,7 +317,7 @@ async def draft_delete(data: models.api.DraftDeleteArgs) -> 
DictResponse:
         release_name = sql.release_name(data.project, data.version)
         release = await db_data.release(
             name=release_name, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, 
_committee=True
-        ).demand(exceptions.NotFound())
+        ).demand(exceptions.NotFound(f"Draft release '{release_name}' not 
found"))
         if release.project.committee is None:
             raise exceptions.NotFound("Project has no committee")
         _committee_member_or_admin(release.project.committee, asf_uid)
@@ -333,9 +338,9 @@ async def draft_delete(data: models.api.DraftDeleteArgs) -> 
DictResponse:
 
 
 # This is the only POST endpoint that does not require a JWT
[email protected]("/jwt", methods=["POST"])
-@quart_schema.validate_request(models.api.JwtArgs)
-async def jwt(data: models.api.JwtArgs) -> DictResponse:
[email protected]("/jwt/create", methods=["POST"])
+@quart_schema.validate_request(models.api.JwtCreateArgs)
+async def jwt_create(data: models.api.JwtCreateArgs) -> DictResponse:
     """
     Create a JWT from a valid PAT.
     """
@@ -349,8 +354,8 @@ async def jwt(data: models.api.JwtArgs) -> DictResponse:
         raise exceptions.Unauthorized("Invalid PAT")
 
     jwt_token = jwtoken.issue(data.asfuid)
-    return models.api.JwtResults(
-        endpoint="/jwt",
+    return models.api.JwtCreateResults(
+        endpoint="/jwt/create",
         asfuid=data.asfuid,
         jwt=jwt_token,
     ).model_dump(), 200
@@ -363,6 +368,8 @@ async def jwt(data: models.api.JwtArgs) -> DictResponse:
 async def keys_endpoint(query_args: models.api.KeysQuery) -> DictResponse:
     """
     All public OpenPGP keys, with pagination support.
+
+    Warning: this endpoint is deprecated.
     """
     # TODO: Rather than pagination, let's support keys by committee and by user
     # That way, consumers can scroll through committees or users
@@ -394,7 +401,10 @@ async def keys_endpoint(query_args: models.api.KeysQuery) 
-> DictResponse:
 @quart_schema.validate_response(models.api.KeysAddResults, 200)
 async def keys_add(data: models.api.KeysAddArgs) -> DictResponse:
     """
-    Add a public OpenPGP key to a list of committees.
+    Add a public OpenPGP key to all specified committees.
+
+    Once associated with the specified committees, the key will appear in the
+    automatically generated KEYS file for each committee.
     """
     asf_uid = _jwt_asf_uid()
     selected_committee_names = data.committees
@@ -426,6 +436,8 @@ async def keys_add(data: models.api.KeysAddArgs) -> 
DictResponse:
 async def keys_delete(data: models.api.KeysDeleteArgs) -> DictResponse:
     """
     Delete a public OpenPGP key from all committees.
+
+    Warning: we plan to change how key deletion works.
     """
     asf_uid = _jwt_asf_uid()
     fingerprint = data.fingerprint.lower()
@@ -470,10 +482,14 @@ async def keys_delete(data: models.api.KeysDeleteArgs) -> 
DictResponse:
 async def keys_get(fingerprint: str) -> DictResponse:
     """
     A single public OpenPGP key by fingerprint.
+
+    All public OpenPGP keys stored within the database are accessible.
     """
     _simple_check(fingerprint)
     async with db.session() as data:
-        key = await 
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
+        key = await 
data.public_signing_key(fingerprint=fingerprint.lower()).demand(
+            exceptions.NotFound(f"Key '{fingerprint}' not found")
+        )
     return models.api.KeysGetResults(
         endpoint="/keys/get",
         key=key,
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 49bb188..337b1dc 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -59,6 +59,16 @@ class PathInfo(schema.Strict):
     warnings: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
 
 
+async def candidate_drafts(project: sql.Project) -> list[sql.Release]:
+    """Get the candidate drafts for the project."""
+    return await releases_by_phase(project, 
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+
+
+async def candidates(project: sql.Project) -> list[sql.Release]:
+    """Get the candidate releases for the project."""
+    return await releases_by_phase(project, sql.ReleasePhase.RELEASE_CANDIDATE)
+
+
 @contextlib.asynccontextmanager
 async def ephemeral_gpg_home() -> AsyncGenerator[str]:
     """Create a temporary directory for an isolated GPG home, and clean it up 
on exit."""
@@ -66,6 +76,11 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
         yield str(temp_dir)
 
 
+async def full_releases(project: sql.Project) -> list[sql.Release]:
+    """Get the full releases for the project."""
+    return await releases_by_phase(project, sql.ReleasePhase.RELEASE)
+
+
 async def has_failing_checks(release: sql.Release, revision_number: str, 
caller_data: db.Session | None = None) -> bool:
     async with db.ensure_session(caller_data) as data:
         query = (
@@ -106,6 +121,11 @@ async def path_info(release: sql.Release, paths: 
list[pathlib.Path]) -> PathInfo
     return info
 
 
+async def previews(project: sql.Project) -> list[sql.Release]:
+    """Get the preview releases for the project."""
+    return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
+
+
 async def release_delete(
     release_name: str, phase: db.Opt[sql.ReleasePhase] = db.NOT_SET, 
include_downloads: bool = True
 ) -> None:
@@ -144,6 +164,38 @@ async def release_delete(
     await _delete_release_data_filesystem(release_dir, release_name)
 
 
+async def releases_by_phase(project: sql.Project, phase: sql.ReleasePhase) -> 
list[sql.Release]:
+    """Get the releases for the project by phase."""
+
+    query = (
+        sqlmodel.select(sql.Release)
+        .where(
+            sql.Release.project_name == project.name,
+            sql.Release.phase == phase,
+        )
+        
.order_by(sql.validate_instrumented_attribute(sql.Release.created).desc())
+    )
+
+    results = []
+    async with db.session() as data:
+        for result in (await data.execute(query)).all():
+            release = result[0]
+            results.append(release)
+
+    for release in results:
+        # Don't need to eager load and lose it when the session closes
+        release.project = project
+    return results
+
+
+async def releases_in_progress(project: sql.Project) -> list[sql.Release]:
+    """Get the releases in progress for the project."""
+    drafts = await candidate_drafts(project)
+    cands = await candidates(project)
+    prevs = await previews(project)
+    return drafts + cands + prevs
+
+
 async def tasks_ongoing(project_name: str, version_name: str, revision_number: 
str | None = None) -> int:
     tasks = sqlmodel.select(sqlalchemy.func.count()).select_from(sql.Task)
     async with db.session() as data:
@@ -321,55 +373,3 @@ async def _successes_errors_warnings(
     for error in errors:
         if primary_rel_path := error.primary_rel_path:
             info.errors.setdefault(pathlib.Path(primary_rel_path), 
[]).append(error)
-
-
-async def releases_by_phase(project: sql.Project, phase: sql.ReleasePhase) -> 
list[sql.Release]:
-    """Get the releases for the project by phase."""
-
-    query = (
-        sqlmodel.select(sql.Release)
-        .where(
-            sql.Release.project_name == project.name,
-            sql.Release.phase == phase,
-        )
-        
.order_by(sql.validate_instrumented_attribute(sql.Release.created).desc())
-    )
-
-    results = []
-    async with db.session() as data:
-        for result in (await data.execute(query)).all():
-            release = result[0]
-            results.append(release)
-
-    for release in results:
-        # Don't need to eager load and lose it when the session closes
-        release.project = project
-    return results
-
-
-async def candidate_drafts(project: sql.Project) -> list[sql.Release]:
-    """Get the candidate drafts for the project."""
-    return await releases_by_phase(project, 
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
-
-
-async def candidates(project: sql.Project) -> list[sql.Release]:
-    """Get the candidate releases for the project."""
-    return await releases_by_phase(project, sql.ReleasePhase.RELEASE_CANDIDATE)
-
-
-async def previews(project: sql.Project) -> list[sql.Release]:
-    """Get the preview releases for the project."""
-    return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
-
-
-async def full_releases(project: sql.Project) -> list[sql.Release]:
-    """Get the full releases for the project."""
-    return await releases_by_phase(project, sql.ReleasePhase.RELEASE)
-
-
-async def releases_in_progress(project: sql.Project) -> list[sql.Release]:
-    """Get the releases in progress for the project."""
-    drafts = await candidate_drafts(project)
-    cands = await candidates(project)
-    prevs = await previews(project)
-    return drafts + cands + prevs
diff --git a/atr/models/api.py b/atr/models/api.py
index 820d466..2abeb5f 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -66,7 +66,7 @@ class ChecksListResults(schema.Strict):
 
 class ChecksOngoingResults(schema.Strict):
     endpoint: Literal["/checks/ongoing"] = schema.Field(alias="endpoint")
-    ongoing: int
+    ongoing: int = schema.Field(..., **example(10))
 
 
 class CommitteesGetResults(schema.Strict):
@@ -90,13 +90,13 @@ class CommitteesProjectsResults(schema.Strict):
 
 
 class DraftDeleteArgs(schema.Strict):
-    project: str
-    version: str
+    project: str = schema.Field(..., **example("example"))
+    version: str = schema.Field(..., **example("0.0.1"))
 
 
 class DraftDeleteResults(schema.Strict):
     endpoint: Literal["/draft/delete"] = schema.Field(alias="endpoint")
-    success: str
+    success: str = schema.Field(..., **example("Draft 'example-0.0.1' 
deleted"))
 
 
 class ListResults(schema.Strict):
@@ -104,15 +104,15 @@ class ListResults(schema.Strict):
     rel_paths: Sequence[str]
 
 
-class JwtArgs(schema.Strict):
-    asfuid: str
-    pat: str
+class JwtCreateArgs(schema.Strict):
+    asfuid: str = schema.Field(..., **example("user"))
+    pat: str = schema.Field(..., 
**example("8M5t4GCU63EdOy4NNXgXn7o-bc-muK8TRg5W-DeBaWY"))
 
 
-class JwtResults(schema.Strict):
-    endpoint: Literal["/jwt"] = schema.Field(alias="endpoint")
-    asfuid: str
-    jwt: str
+class JwtCreateResults(schema.Strict):
+    endpoint: Literal["/jwt/create"] = schema.Field(alias="endpoint")
+    asfuid: str = schema.Field(..., **example("user"))
+    jwt: str = schema.Field(..., 
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
 
 
 @dataclasses.dataclass
@@ -124,19 +124,21 @@ class KeysQuery:
 class KeysResults(schema.Strict):
     endpoint: Literal["/keys"] = schema.Field(alias="endpoint")
     data: Sequence[sql.PublicSigningKey]
-    count: int
+    count: int = schema.Field(..., **example(10))
 
 
 class KeysAddArgs(schema.Strict):
-    asfuid: str
-    key: str
-    committees: list[str]
+    asfuid: str = schema.Field(..., **example("user"))
+    key: str = schema.Field(
+        ..., **example("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n...\n-----END 
PGP PUBLIC KEY BLOCK-----\n")
+    )
+    committees: list[str] = schema.Field(..., **example(["example"]))
 
 
 class KeysAddResults(schema.Strict):
     endpoint: Literal["/keys/add"] = schema.Field(alias="endpoint")
-    success: str
-    fingerprint: str
+    success: str = schema.Field(..., **example("Key added"))
+    fingerprint: str = schema.Field(..., 
**example("0123456789abcdef0123456789abcdef01234567"))
 
 
 # class KeysCommitteeResults(schema.Strict):
@@ -145,12 +147,12 @@ class KeysAddResults(schema.Strict):
 
 
 class KeysDeleteArgs(schema.Strict):
-    fingerprint: str
+    fingerprint: str = schema.Field(..., 
**example("0123456789abcdef0123456789abcdef01234567"))
 
 
 class KeysDeleteResults(schema.Strict):
     endpoint: Literal["/keys/delete"] = schema.Field(alias="endpoint")
-    success: str
+    success: str = schema.Field(..., **example("Key deleted"))
 
 
 class KeysGetResults(schema.Strict):
@@ -159,15 +161,17 @@ class KeysGetResults(schema.Strict):
 
 
 class KeysUploadArgs(schema.Strict):
-    filetext: str
-    committee: str
+    filetext: str = schema.Field(
+        ..., **example("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n...\n-----END 
PGP PUBLIC KEY BLOCK-----\n")
+    )
+    committee: str = schema.Field(..., **example("example"))
 
 
 class KeysUploadException(schema.Strict):
     status: Literal["error"] = schema.Field(alias="status")
     key: sql.PublicSigningKey | None
-    error: str
-    error_type: str
+    error: str = schema.Field(..., **example("Error message"))
+    error_type: str = schema.Field(..., **example("KeysUploadError"))
 
 
 class KeysUploadResult(schema.Strict):
@@ -193,9 +197,9 @@ KeysUploadOutcomeAdapter = 
pydantic.TypeAdapter(KeysUploadOutcome)
 class KeysUploadResults(schema.Strict):
     endpoint: Literal["/keys/upload"] = schema.Field(alias="endpoint")
     results: Sequence[KeysUploadResult | KeysUploadException]
-    success_count: int
-    error_count: int
-    submitted_committee: str
+    success_count: int = schema.Field(..., **example(1))
+    error_count: int = schema.Field(..., **example(0))
+    submitted_committee: str = schema.Field(..., **example("example"))
 
 
 class KeysUserResults(schema.Strict):
@@ -421,7 +425,7 @@ Results = Annotated[
     | CommitteesListResults
     | CommitteesProjectsResults
     | DraftDeleteResults
-    | JwtResults
+    | JwtCreateResults
     | KeysResults
     | KeysAddResults
     | KeysDeleteResults
@@ -474,7 +478,7 @@ validate_committees_keys = validator(CommitteesKeysResults)
 validate_committees_list = validator(CommitteesListResults)
 validate_committees_projects = validator(CommitteesProjectsResults)
 validate_draft_delete = validator(DraftDeleteResults)
-validate_jwt = validator(JwtResults)
+validate_jwt_create = validator(JwtCreateResults)
 validate_keys = validator(KeysResults)
 validate_keys_add = validator(KeysAddResults)
 # validate_keys_committee = validator(KeysCommitteeResults)


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

Reply via email to