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]