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 cdada51  Document all committee API endpoints
cdada51 is described below

commit cdada51b02687ac6b1eed15ede28348c022d8ce6
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Jul 28 20:19:30 2025 +0100

    Document all committee API endpoints
---
 atr/blueprints/api/api.py | 113 +++++++++++++++++++++++++++++++++-------------
 atr/models/api.py         |  18 ++++----
 atr/models/sql.py         |  67 ++++++++++++++++-----------
 3 files changed, 132 insertions(+), 66 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 3aaf4f8..40d9a53 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -105,7 +105,7 @@ async def announce_post(data: models.api.AnnounceArgs) -> 
DictResponse:
 @quart_schema.validate_response(models.api.ChecksListResults, 200)
 async def checks_list(project: str, version: str) -> DictResponse:
     """
-    List all of the check results for the latest revision of a release.
+    All of the check results for the latest revision of a release.
 
     Checks are only conducted during the compose a draft phase. This endpoint
     only returns the checks for the most recent draft revision. Once a release
@@ -145,7 +145,7 @@ async def checks_list(project: str, version: str) -> 
DictResponse:
 @quart_schema.validate_response(models.api.ChecksListResults, 200)
 async def checks_list_revision(project: str, version: str, revision: str) -> 
DictResponse:
     """
-    List all of the check results for a specific revision of a release.
+    All of the check results for a specific revision of a release.
 
     Checks are only conducted during the compose a draft phase. This endpoint
     only returns the checks for the specified draft revision. Once a release
@@ -188,7 +188,7 @@ async def checks_ongoing(
     revision: str | None = None,
 ) -> DictResponse:
     """
-    Count the unfinished check results for a specifed or latest revision of a 
release.
+    The number of unfinished check results for a specifed or latest revision 
of a release.
 
     Checks are only conducted during the compose a draft phase. This endpoint
     returns the number of ongoing checks for the specified draft revision if
@@ -217,15 +217,22 @@ async def checks_ongoing(
 
 
 # TODO: Rename all paths to avoid clashes
[email protected]("/committees/<name>")
-@quart_schema.validate_response(models.api.CommitteesResults, 200)
-async def committees(name: str) -> DictResponse:
-    """Get a specific committee by name."""
[email protected]("/committees/get/<name>")
+@quart_schema.validate_response(models.api.CommitteesGetResults, 200)
+async def committees_get(name: str) -> DictResponse:
+    """
+    A specific committee by name.
+
+    The name of the committee is the name without any prefixes or suffixes such
+    as "Apache" or "PMC", in lower case, and with hyphens instead of spaces.
+    The Apache Simple Example PMC, for example, would have the name
+    "simple-example".
+    """
     _simple_check(name)
     async with db.session() as data:
-        committee = await 
data.committee(name=name).demand(exceptions.NotFound())
-    return models.api.CommitteesResults(
-        endpoint="/committees",
+        committee = await 
data.committee(name=name).demand(exceptions.NotFound(f"Committee '{name}' was 
not found"))
+    return models.api.CommitteesGetResults(
+        endpoint="/committees/get",
         committee=committee,
     ).model_dump(), 200
 
@@ -233,10 +240,19 @@ async def committees(name: str) -> DictResponse:
 @api.BLUEPRINT.route("/committees/keys/<name>")
 @quart_schema.validate_response(models.api.CommitteesKeysResults, 200)
 async def committees_keys(name: str) -> DictResponse:
-    """List all public signing keys associated with a specific committee."""
+    """
+    Public OpenPGP keys associated with a specific committee.
+
+    The name of the committee is the name without any prefixes or suffixes such
+    as "Apache" or "PMC", in lower case, and with hyphens instead of spaces.
+    The Apache Simple Example PMC, for example, would have the name
+    "simple-example".
+    """
     _simple_check(name)
     async with db.session() as data:
-        committee = await data.committee(name=name, 
_public_signing_keys=True).demand(exceptions.NotFound())
+        committee = await data.committee(name=name, 
_public_signing_keys=True).demand(
+            exceptions.NotFound(f"Committee '{name}' was not found")
+        )
     return models.api.CommitteesKeysResults(
         endpoint="/committees/keys",
         keys=committee.public_signing_keys,
@@ -246,7 +262,11 @@ async def committees_keys(name: str) -> DictResponse:
 @api.BLUEPRINT.route("/committees/list")
 @quart_schema.validate_response(models.api.CommitteesListResults, 200)
 async def committees_list() -> DictResponse:
-    """List all committees in the database."""
+    """
+    All committees.
+
+    The list of committees is returned in no particular order.
+    """
     async with db.session() as data:
         committees = await data.committee().all()
     return models.api.CommitteesListResults(
@@ -258,10 +278,19 @@ async def committees_list() -> DictResponse:
 @api.BLUEPRINT.route("/committees/projects/<name>")
 @quart_schema.validate_response(models.api.CommitteesProjectsResults, 200)
 async def committees_projects(name: str) -> DictResponse:
-    """List all projects for a specific committee."""
+    """
+    Projects managed by a specific committee.
+
+    The name of the committee is the name without any prefixes or suffixes such
+    as "Apache" or "PMC", in lower case, and with hyphens instead of spaces.
+    The Apache Simple Example PMC, for example, would have the name
+    "simple-example".
+    """
     _simple_check(name)
     async with db.session() as data:
-        committee = await data.committee(name=name, 
_projects=True).demand(exceptions.NotFound())
+        committee = await data.committee(name=name, _projects=True).demand(
+            exceptions.NotFound(f"Committee '{name}' was not found")
+        )
     return models.api.CommitteesProjectsResults(
         endpoint="/committees/projects",
         projects=committee.projects,
@@ -274,6 +303,9 @@ async def committees_projects(name: str) -> DictResponse:
 @quart_schema.validate_request(models.api.DraftDeleteArgs)
 @quart_schema.validate_response(models.api.DraftDeleteResults, 200)
 async def draft_delete(data: models.api.DraftDeleteArgs) -> DictResponse:
+    """
+    Delete a draft release.
+    """
     asf_uid = _jwt_asf_uid()
 
     async with db.session() as db_data:
@@ -304,7 +336,9 @@ async def draft_delete(data: models.api.DraftDeleteArgs) -> 
DictResponse:
 @api.BLUEPRINT.route("/jwt", methods=["POST"])
 @quart_schema.validate_request(models.api.JwtArgs)
 async def jwt(data: models.api.JwtArgs) -> DictResponse:
-    """Generate a JWT from a valid PAT."""
+    """
+    Create a JWT from a valid PAT.
+    """
     # Expects {"asfuid": "uid", "pat": "pat-token"}
     # Returns {"asfuid": "uid", "jwt": "jwt-token"}
     token_hash = hashlib.sha3_256(data.pat.encode()).hexdigest()
@@ -327,7 +361,9 @@ async def jwt(data: models.api.JwtArgs) -> DictResponse:
 @quart_schema.validate_querystring(models.api.KeysQuery)
 @quart_schema.validate_response(models.api.KeysResults, 200)
 async def keys_endpoint(query_args: models.api.KeysQuery) -> DictResponse:
-    """List all public signing keys with pagination support."""
+    """
+    All public OpenPGP keys, with pagination support.
+    """
     # TODO: Rather than pagination, let's support keys by committee and by user
     # That way, consumers can scroll through committees or users
     # Which performs logical pagination, rather than arbitrary window 
pagination
@@ -357,6 +393,9 @@ async def keys_endpoint(query_args: models.api.KeysQuery) 
-> DictResponse:
 @quart_schema.validate_request(models.api.KeysAddArgs)
 @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.
+    """
     asf_uid = _jwt_asf_uid()
     selected_committee_names = data.committees
 
@@ -385,6 +424,9 @@ async def keys_add(data: models.api.KeysAddArgs) -> 
DictResponse:
 @quart_schema.validate_request(models.api.KeysDeleteArgs)
 @quart_schema.validate_response(models.api.KeysDeleteResults, 200)
 async def keys_delete(data: models.api.KeysDeleteArgs) -> DictResponse:
+    """
+    Delete a public OpenPGP key from all committees.
+    """
     asf_uid = _jwt_asf_uid()
     fingerprint = data.fingerprint.lower()
 
@@ -407,24 +449,28 @@ async def keys_delete(data: models.api.KeysDeleteArgs) -> 
DictResponse:
     ).model_dump(), 200
 
 
[email protected]("/keys/committee/<committee>")
-@quart_schema.validate_response(models.api.KeysUserResults, 200)
-async def keys_committee(committee: str) -> DictResponse:
-    """Return all public signing keys for a specific committee."""
-    _simple_check(committee)
-    async with db.session() as data:
-        committee_object = await data.committee(name=committee, 
_public_signing_keys=True).demand(exceptions.NotFound())
-        keys = committee_object.public_signing_keys
-    return models.api.KeysCommitteeResults(
-        endpoint="/keys/committee",
-        keys=keys,
-    ).model_dump(), 200
+# @api.BLUEPRINT.route("/keys/committee/<committee>")
+# @quart_schema.validate_response(models.api.KeysUserResults, 200)
+# async def keys_committee(committee: str) -> DictResponse:
+#     """Return all public signing keys for a specific committee."""
+#     _simple_check(committee)
+#     async with db.session() as data:
+#         committee_object = await data.committee(
+#             name=committee, _public_signing_keys=True
+#         ).demand(exceptions.NotFound(f"Committee '{committee}' was not 
found"))
+#         keys = committee_object.public_signing_keys
+#     return models.api.KeysCommitteeResults(
+#         endpoint="/keys/committee",
+#         keys=keys,
+#     ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/keys/get/<fingerprint>")
 @quart_schema.validate_response(models.api.KeysGetResults, 200)
 async def keys_get(fingerprint: str) -> DictResponse:
-    """Return a single public signing key by fingerprint."""
+    """
+    A single public OpenPGP key by fingerprint.
+    """
     _simple_check(fingerprint)
     async with db.session() as data:
         key = await 
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
@@ -440,6 +486,9 @@ async def keys_get(fingerprint: str) -> DictResponse:
 @quart_schema.validate_request(models.api.KeysUploadArgs)
 @quart_schema.validate_response(models.api.KeysUploadResults, 200)
 async def keys_upload(data: models.api.KeysUploadArgs) -> DictResponse:
+    """
+    Upload a new public OpenPGP key to a committee.
+    """
     asf_uid = _jwt_asf_uid()
     filetext = data.filetext
     selected_committee_name = data.committee
@@ -492,7 +541,9 @@ async def keys_upload(data: models.api.KeysUploadArgs) -> 
DictResponse:
 @api.BLUEPRINT.route("/keys/user/<asf_uid>")
 @quart_schema.validate_response(models.api.KeysUserResults, 200)
 async def keys_user(asf_uid: str) -> DictResponse:
-    """Return all public signing keys for a specific user."""
+    """
+    All public OpenPGP keys for a specific user.
+    """
     _simple_check(asf_uid)
     async with db.session() as data:
         keys = await data.public_signing_key(apache_uid=asf_uid).all()
diff --git a/atr/models/api.py b/atr/models/api.py
index 3a565d8..820d466 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -69,8 +69,8 @@ class ChecksOngoingResults(schema.Strict):
     ongoing: int
 
 
-class CommitteesResults(schema.Strict):
-    endpoint: Literal["/committees"] = schema.Field(alias="endpoint")
+class CommitteesGetResults(schema.Strict):
+    endpoint: Literal["/committees/get"] = schema.Field(alias="endpoint")
     committee: sql.Committee
 
 
@@ -139,9 +139,9 @@ class KeysAddResults(schema.Strict):
     fingerprint: str
 
 
-class KeysCommitteeResults(schema.Strict):
-    endpoint: Literal["/keys/committee"] = schema.Field(alias="endpoint")
-    keys: Sequence[sql.PublicSigningKey]
+# class KeysCommitteeResults(schema.Strict):
+#     endpoint: Literal["/keys/committee"] = schema.Field(alias="endpoint")
+#     keys: Sequence[sql.PublicSigningKey]
 
 
 class KeysDeleteArgs(schema.Strict):
@@ -416,7 +416,7 @@ Results = Annotated[
     AnnounceResults
     | ChecksListResults
     | ChecksOngoingResults
-    | CommitteesResults
+    | CommitteesGetResults
     | CommitteesKeysResults
     | CommitteesListResults
     | CommitteesProjectsResults
@@ -426,7 +426,7 @@ Results = Annotated[
     | KeysAddResults
     | KeysDeleteResults
     | KeysGetResults
-    | KeysCommitteeResults
+    # | KeysCommitteeResults
     | KeysUploadResults
     | KeysUserResults
     | ListResults
@@ -469,7 +469,7 @@ def validator[T](t: type[T]) -> Callable[[Any], T]:
 validate_announce = validator(AnnounceResults)
 validate_checks_list = validator(ChecksListResults)
 validate_checks_ongoing = validator(ChecksOngoingResults)
-validate_committees = validator(CommitteesResults)
+validate_committees_get = validator(CommitteesGetResults)
 validate_committees_keys = validator(CommitteesKeysResults)
 validate_committees_list = validator(CommitteesListResults)
 validate_committees_projects = validator(CommitteesProjectsResults)
@@ -477,7 +477,7 @@ validate_draft_delete = validator(DraftDeleteResults)
 validate_jwt = validator(JwtResults)
 validate_keys = validator(KeysResults)
 validate_keys_add = validator(KeysAddResults)
-validate_keys_committee = validator(KeysCommitteeResults)
+# validate_keys_committee = validator(KeysCommitteeResults)
 validate_keys_delete = validator(KeysDeleteResults)
 validate_keys_get = validator(KeysGetResults)
 validate_keys_upload = validator(KeysUploadResults)
diff --git a/atr/models/sql.py b/atr/models/sql.py
index eb2548c..9af0a0a 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -192,6 +192,11 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
 
 # SQL models
 
+
+def example(value: Any) -> dict[Literal["schema_extra"], dict[str, Any]]:
+    return {"schema_extra": {"json_schema_extra": {"examples": [value]}}}
+
+
 # SQL models with no dependencies
 
 
@@ -309,8 +314,8 @@ class TextValue(sqlmodel.SQLModel, table=True):
 class Committee(sqlmodel.SQLModel, table=True):
     # TODO: Consider using key or label for primary string keys
     # Then we can use simply "name" for full_name, and make it str rather than 
str | None
-    name: str = sqlmodel.Field(unique=True, primary_key=True)
-    full_name: str | None = sqlmodel.Field(default=None)
+    name: str = sqlmodel.Field(unique=True, primary_key=True, 
**example("example"))
+    full_name: str | None = sqlmodel.Field(default=None, **example("Example"))
     # True only if this is an incubator podling with a PPMC
     is_podling: bool = sqlmodel.Field(default=False)
 
@@ -331,9 +336,15 @@ class Committee(sqlmodel.SQLModel, table=True):
     # M-1: Project -> Committee
     projects: list["Project"] = 
sqlmodel.Relationship(back_populates="committee")
 
-    committee_members: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-    committers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-    release_managers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    committee_members: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["sbp", "tn", "wave"])
+    )
+    committers: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["sbp", "tn", "wave"])
+    )
+    release_managers: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["wave"])
+    )
 
     # M-M: Committee -> [PublicSigningKey]
     # M-M: PublicSigningKey -> [Committee]
@@ -356,12 +367,12 @@ def see_also(arg: Any) -> None:
 class Project(sqlmodel.SQLModel, table=True):
     # TODO: Consider using key or label for primary string keys
     # Then we can use simply "name" for full_name, and make it str rather than 
str | None
-    name: str = sqlmodel.Field(unique=True, primary_key=True)
+    name: str = sqlmodel.Field(unique=True, primary_key=True, 
**example("example"))
     # TODO: Ideally full_name would be unique for str only, but that's complex
     # We always include "Apache" in the full_name
-    full_name: str | None = sqlmodel.Field(default=None)
+    full_name: str | None = sqlmodel.Field(default=None, **example("Apache 
Example"))
 
-    status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE)
+    status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE, 
**example(ProjectStatus.ACTIVE))
 
     # M-1: Project -> Project
     # 1-M: (Project.child_project is missing, would be Project -> [Project])
@@ -369,13 +380,13 @@ class Project(sqlmodel.SQLModel, table=True):
     # NOTE: Neither "Project" | None nor "Project | None" works
     super_project: Optional["Project"] = sqlmodel.Relationship()
 
-    description: str | None = sqlmodel.Field(default=None)
-    category: str | None = sqlmodel.Field(default=None)
-    programming_languages: str | None = sqlmodel.Field(default=None)
+    description: str | None = sqlmodel.Field(default=None, **example("Example 
is a simple example project"))
+    category: str | None = sqlmodel.Field(default=None, 
**example("data,storage"))
+    programming_languages: str | None = sqlmodel.Field(default=None, 
**example("c,python"))
 
     # M-1: Project -> Committee
     # 1-M: Committee -> [Project]
-    committee_name: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.name")
+    committee_name: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.name", **example("example"))
     committee: Committee | None = 
sqlmodel.Relationship(back_populates="projects")
     see_also(Committee.projects)
 
@@ -396,9 +407,11 @@ class Project(sqlmodel.SQLModel, table=True):
     )
 
     created: datetime.datetime = sqlmodel.Field(
-        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
+        default_factory=lambda: datetime.datetime.now(datetime.UTC),
+        sa_column=sqlalchemy.Column(UTCDateTime),
+        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
-    created_by: str | None = sqlmodel.Field(default=None)
+    created_by: str | None = sqlmodel.Field(default=None, **example("user"))
 
     @property
     def display_name(self) -> str:
@@ -646,10 +659,6 @@ class Release(sqlmodel.SQLModel, table=True):
 # SQL models referencing Committee, Project, or Release
 
 
-def example(value: Any) -> dict[Literal["schema_extra"], dict[str, Any]]:
-    return {"schema_extra": {"json_schema_extra": {"examples": [value]}}}
-
-
 # CheckResult: Release
 class CheckResult(sqlmodel.SQLModel, table=True):
     # TODO: We have default=None here with a field typed int, not int | None
@@ -698,13 +707,17 @@ class DistributionChannel(sqlmodel.SQLModel, table=True):
 # PublicSigningKey: Committee
 class PublicSigningKey(sqlmodel.SQLModel, table=True):
     # The fingerprint must be stored as lowercase hex
-    fingerprint: str = sqlmodel.Field(primary_key=True, unique=True)
+    fingerprint: str = sqlmodel.Field(
+        primary_key=True, unique=True, 
**example("0123456789abcdef0123456789abcdef01234567")
+    )
     # The algorithm is an RFC 4880 algorithm ID
-    algorithm: int
+    algorithm: int = sqlmodel.Field(**example(1))
     # Key length in bits
-    length: int
+    length: int = sqlmodel.Field(**example(4096))
     # Creation date
-    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    created: datetime.datetime = sqlmodel.Field(
+        sa_column=sqlalchemy.Column(UTCDateTime), 
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC))
+    )
     # Latest self signature
     latest_self_signature: datetime.datetime | None = sqlmodel.Field(
         default=None, sa_column=sqlalchemy.Column(UTCDateTime)
@@ -712,15 +725,17 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
     # Expiration date
     expires: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
     # The primary UID declared in the key
-    primary_declared_uid: str | None
+    primary_declared_uid: str | None = sqlmodel.Field(**example("User 
<[email protected]>"))
     # The secondary UIDs declared in the key
     secondary_declared_uids: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["User <[email protected]>"])
     )
     # The UID used by Apache, if available
-    apache_uid: str | None
+    apache_uid: str | None = sqlmodel.Field(**example("user"))
     # The ASCII armored key
-    ascii_armored_key: str
+    ascii_armored_key: str = sqlmodel.Field(
+        **example("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n...\n-----END PGP 
PUBLIC KEY BLOCK-----\n")
+    )
 
     # M-M: PublicSigningKey -> [Committee]
     # M-M: Committee -> [PublicSigningKey]


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

Reply via email to