Skip to content

Commit 9771109

Browse files
committed
Use Pydantic to type check API responses
1 parent 8736afc commit 9771109

File tree

4 files changed

+315
-24
lines changed

4 files changed

+315
-24
lines changed

hv4gha/gh.py

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""GitHub specific code"""
22

33
import json
4-
from datetime import datetime, timezone
5-
from typing import Final, TypedDict
4+
from datetime import datetime
5+
from typing import Final, Literal, TypedDict
66

77
import requests
8+
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
9+
10+
PermARW = None | Literal["admin", "read", "write"]
11+
PermRW = None | Literal["read", "write"]
12+
PermR = None | Literal["read"]
13+
PermW = None | Literal["write"]
814

915

1016
class TokenResponse(TypedDict, total=False):
@@ -32,6 +38,89 @@ class NotInstalledError(Exception):
3238
"""The GitHub App isn't installed in the specified account"""
3339

3440

41+
class GitHubErrors(BaseModel):
42+
"""
43+
https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28
44+
"""
45+
46+
message: str
47+
48+
49+
class AccountInfo(BaseModel):
50+
"""Part of Installation"""
51+
52+
login: str = Field(
53+
max_length=39, pattern=r"^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$"
54+
)
55+
56+
57+
class Installation(BaseModel):
58+
"""
59+
https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#list-installations-for-the-authenticated-app
60+
"""
61+
62+
id: int
63+
account: AccountInfo
64+
65+
66+
class TokenPermissions(BaseModel):
67+
"""Part of AccessToken"""
68+
69+
# Repository permissions
70+
actions: PermRW = None
71+
administration: PermRW = None
72+
checks: PermRW = None
73+
contents: PermRW = None
74+
deployments: PermRW = None
75+
environments: PermRW = None
76+
issues: PermRW = None
77+
metadata: PermRW = None
78+
packages: PermRW = None
79+
pages: PermRW = None
80+
pull_requests: PermRW = None
81+
repository_hooks: PermRW = None
82+
repository_projects: PermARW = None
83+
secret_scanning_alerts: PermRW = None
84+
secrets: PermRW = None
85+
security_events: PermRW = None
86+
single_file: PermRW = None
87+
statuses: PermRW = None
88+
vulnerability_alerts: PermRW = None
89+
workflows: PermW = None
90+
# Organizational permissions
91+
members: PermRW = None
92+
organization_administration: PermRW = None
93+
organization_custom_roles: PermRW = None
94+
organization_announcement_banners: PermRW = None
95+
organization_hooks: PermRW = None
96+
organization_personal_access_tokens: PermRW = None
97+
organization_personal_access_token_requests: PermRW = None
98+
organization_plan: PermR = None
99+
organization_projects: PermARW = None
100+
organization_packages: PermRW = None
101+
organization_secrets: PermRW = None
102+
organization_self_hosted_runners: PermRW = None
103+
organization_user_blocking: PermRW = None
104+
team_discussions: PermRW = None
105+
106+
107+
class Repository(BaseModel):
108+
"""Part of AccessToken"""
109+
110+
name: str = Field(max_length=100, pattern=r"^[a-zA-Z0-9_\-\.]+$")
111+
112+
113+
class AccessToken(BaseModel):
114+
"""
115+
https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
116+
"""
117+
118+
token: str
119+
expires_at: datetime
120+
permissions: TokenPermissions
121+
repositories: None | list[Repository] = None
122+
123+
35124
class GitHubApp:
36125
"""GitHub App Access Tokens, etc"""
37126

@@ -68,16 +157,23 @@ def __find_installation(self) -> str:
68157
)
69158
response.raise_for_status()
70159
except requests.exceptions.HTTPError as http_error:
71-
error_message: str
72160
try:
73-
error_message = http_error.response.json()["message"]
161+
errors_bm = GitHubErrors(**http_error.response.json())
162+
error_message = errors_bm.message
74163
except Exception: # pylint: disable=broad-exception-caught
75164
error_message = "<Failed to parse GitHub API error response>"
76165
raise InstallationLookupError(error_message) from http_error
77166

78-
for installation in response.json():
79-
if installation["account"]["login"].lower() == self.account.lower():
80-
return str(installation["id"])
167+
try:
168+
ita = TypeAdapter(list[Installation])
169+
installations = ita.validate_python(response.json())
170+
except ValidationError as validation_error:
171+
error_message = "<Failed to parse Installations API response>"
172+
raise InstallationLookupError(error_message) from validation_error
173+
174+
for installation in installations:
175+
if installation.account.login.lower() == self.account.lower():
176+
return str(installation.id)
81177

82178
if "next" in response.links.keys():
83179
pagination_params["page"] += 1
@@ -125,26 +221,28 @@ def issue_token(
125221
)
126222
response.raise_for_status()
127223
except requests.exceptions.HTTPError as http_error:
128-
error_message: str
129224
try:
130-
error_message = http_error.response.json()["message"]
225+
errors_bm = GitHubErrors(**http_error.response.json())
226+
error_message = errors_bm.message
131227
except Exception: # pylint: disable=broad-exception-caught
132228
error_message = "<Failed to parse GitHub API error response>"
133229
raise TokenIssueError(error_message) from http_error
134230

135-
expiry = datetime.strptime(
136-
response.json()["expires_at"], "%Y-%m-%dT%H:%M:%SZ"
137-
).replace(tzinfo=timezone.utc)
231+
try:
232+
access_token_bm = AccessToken(**response.json())
233+
except ValidationError as validation_error:
234+
error_message = "<Failed to parse Token Issue API response>"
235+
raise TokenIssueError(error_message) from validation_error
138236

139237
access_token: TokenResponse = {
140-
"access_token": response.json()["token"],
141-
"expires_at": expiry,
142-
"permissions": response.json()["permissions"],
238+
"access_token": access_token_bm.token,
239+
"expires_at": access_token_bm.expires_at,
240+
"permissions": access_token_bm.permissions.model_dump(exclude_unset=True),
143241
}
144242

145-
if "repositories" in response.json().keys():
243+
if access_token_bm.repositories is not None:
146244
access_token["repositories"] = sorted(
147-
[repo["name"] for repo in response.json()["repositories"]]
245+
[repo.name for repo in access_token_bm.repositories]
148246
)
149247

150248
return access_token

hv4gha/vault.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import requests
1010
from cryptography.hazmat.primitives import hashes, keywrap, serialization
1111
from cryptography.hazmat.primitives.asymmetric import padding, rsa
12+
from pydantic import BaseModel, ValidationError
1213

1314

1415
class VaultAPIError(Exception):
@@ -31,6 +32,42 @@ class WrappingKeyDownloadError(VaultAPIError):
3132
"""Failure to download the Vault Transit wrapping key"""
3233

3334

35+
class VaultErrors(BaseModel):
36+
"""
37+
https://developer.hashicorp.com/vault/api-docs#error-response
38+
"""
39+
40+
errors: list[str]
41+
42+
43+
class JWTData(BaseModel):
44+
"""Part of SignedJWT"""
45+
46+
signature: str
47+
48+
49+
class SignedJWT(BaseModel):
50+
"""
51+
https://developer.hashicorp.com/vault/api-docs/secret/transit#sign-data
52+
"""
53+
54+
data: JWTData
55+
56+
57+
class KeyData(BaseModel):
58+
"""Part of WrappingKey"""
59+
60+
public_key: str
61+
62+
63+
class WrappingKey(BaseModel):
64+
"""
65+
https://developer.hashicorp.com/vault/api-docs/secret/transit#get-wrapping-key
66+
"""
67+
68+
data: KeyData
69+
70+
3471
class VaultTransit:
3572
"""Interact with Vault's Transit Secrets Engine"""
3673

@@ -74,14 +111,20 @@ def __download_wrapping_key(self) -> rsa.RSAPublicKey:
74111
)
75112
response.raise_for_status()
76113
except requests.exceptions.HTTPError as http_error:
77-
error_message: str
78114
try:
79-
error_message = "\n".join(http_error.response.json()["errors"])
115+
errors_bm = VaultErrors(**http_error.response.json())
116+
error_message = "\n".join(errors_bm.errors)
80117
except Exception: # pylint: disable=broad-exception-caught
81118
error_message = "<Failed to parse Vault API error response>"
82119
raise WrappingKeyDownloadError(error_message) from http_error
83120

84-
wrapping_pem_key = response.json()["data"]["public_key"].encode()
121+
try:
122+
wrapping_key_bm = WrappingKey(**response.json())
123+
except ValidationError as validation_error:
124+
error_message = "<Failed to parse Wrapping Key API response>"
125+
raise WrappingKeyDownloadError(error_message) from validation_error
126+
127+
wrapping_pem_key = wrapping_key_bm.data.public_key.encode()
85128
wrapping_key = serialization.load_pem_public_key(wrapping_pem_key)
86129

87130
if not isinstance(wrapping_key, rsa.RSAPublicKey):
@@ -125,9 +168,9 @@ def __api_write(
125168
)
126169
response.raise_for_status()
127170
except requests.exceptions.HTTPError as http_error:
128-
error_message: str
129171
try:
130-
error_message = "\n".join(http_error.response.json()["errors"])
172+
errors_bm = VaultErrors(**http_error.response.json())
173+
error_message = "\n".join(errors_bm.errors)
131174
except Exception: # pylint: disable=broad-exception-caught
132175
error_message = "<Failed to parse Vault API error response>"
133176
raise vault_exception(error_message) from http_error
@@ -201,7 +244,13 @@ def sign_jwt(self, key_name: str, app_id: str) -> str:
201244
api_path, payload, JWTSigningError
202245
)
203246

204-
signature: str = response.json()["data"]["signature"].removeprefix("vault:v1:")
247+
try:
248+
signature_bm = SignedJWT(**response.json())
249+
except ValidationError as validation_error:
250+
error_message = "<Failed to parse Sign JWT API response>"
251+
raise JWTSigningError(error_message) from validation_error
252+
253+
signature = signature_bm.data.signature.removeprefix("vault:v1:")
205254
signature = self.__b64str(base64.b64decode(signature), urlsafe=True)
206255

207256
jwt_token = header_and_claims + "." + signature

0 commit comments

Comments
 (0)