Skip to content

Commit e604478

Browse files
committed
feat(codecov): initial client for requests to codecov api
1 parent 4091ded commit e604478

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

src/sentry/codecov/__init__.py

Whitespace-only changes.

src/sentry/codecov/client.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import datetime
2+
import logging
3+
from enum import StrEnum
4+
from typing import TypeAlias
5+
6+
import requests
7+
from rest_framework import status
8+
9+
from sentry import options
10+
from sentry.api.exceptions import SentryAPIException
11+
from sentry.utils import jwt
12+
13+
GitProviderId: TypeAlias = str
14+
15+
16+
class GitProvider(StrEnum):
17+
"""
18+
Enum representing the Git provider that hosts the user/org that a
19+
`CodecovApiClient` instance is acting on behalf of.
20+
21+
Codecov doesn't require this to be GitHub, but that's all that's implemented
22+
for now.
23+
"""
24+
GitHub = "github"
25+
26+
27+
logger = logging.getLogger(__name__)
28+
29+
TIMEOUT_SECONDS = 10
30+
31+
32+
class ConfigurationError(SentryAPIException):
33+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
34+
code = "configuration-error"
35+
36+
37+
class CodecovApiClient:
38+
"""
39+
Thin client for making JWT-authenticated requests to the Codecov API.
40+
41+
For each request, Sentry creates and signs (HS256) a JWT with a key shared
42+
with Codecov. This JWT contains information that Codecov needs to service
43+
the request.
44+
"""
45+
46+
def _create_jwt(self):
47+
now = int(datetime.datetime.now(datetime.UTC).timestamp())
48+
exp = now + 300 # 5 minutes
49+
claims = {
50+
"iss": "https://sentry.io",
51+
"iat": now,
52+
"exp": exp,
53+
}
54+
claims.update(self.custom_claims)
55+
56+
return jwt.encode(claims, self.signing_secret, algorithm="HS256")
57+
58+
def __init__(
59+
self,
60+
git_provider_user: GitProviderId,
61+
git_provider: GitProvider = GitProvider.GitHub,
62+
):
63+
"""
64+
Creates a `CodecovApiClient`.
65+
66+
:param git_provider_user: The ID of the current Sentry user's linked git
67+
provider account, according to the git provider.
68+
:param git_provider: The git provider that the above user's account is
69+
hosted on.
70+
"""
71+
72+
if not (base_url := options.get("codecov.base-url")):
73+
raise ConfigurationError()
74+
75+
if not (signing_secret := options.get("codecov.api-bridge-signing-secret")):
76+
raise ConfigurationError()
77+
78+
self.base_url = base_url
79+
self.signing_secret = signing_secret
80+
self.custom_claims = {
81+
"g_u": git_provider_user,
82+
"g_p": git_provider,
83+
}
84+
85+
def get(self, endpoint: str, params=None, headers=None) -> requests.Response | None:
86+
"""
87+
Makes a GET request to the specified endpoint of the configured Codecov
88+
API host with the provided params and headers.
89+
90+
:param endpoint: The endpoint to request, without the host portion. For
91+
examples: `/api/v2/gh/getsentry/users` or `/graphql`
92+
:param params: Dictionary of query params.
93+
:param headers: Dictionary of request headers.
94+
"""
95+
headers = headers or {}
96+
token = self._create_jwt()
97+
headers.update(jwt.authorization_header(token))
98+
99+
url = f"{self.base_url}{endpoint}"
100+
try:
101+
response = requests.get(url, params=params, headers=headers, timeout=TIMEOUT_SECONDS)
102+
except Exception:
103+
logger.exception("Error when making GET request")
104+
return None
105+
106+
return response
107+
108+
def post(self, endpoint: str, data=None, headers=None) -> requests.Response | None:
109+
"""
110+
Makes a POST request to the specified endpoint of the configured Codecov
111+
API host with the provided data and headers.
112+
113+
:param endpoint: The endpoint to request, without the host portion. For
114+
examples: `/api/v2/gh/getsentry/users` or `/graphql`
115+
:param data: Dictionary of form data.
116+
:param headers: Dictionary of request headers.
117+
"""
118+
headers = headers or {}
119+
token = self._create_jwt()
120+
headers.update(jwt.authorization_header(token))
121+
url = f"{self.base_url}{endpoint}"
122+
try:
123+
response = requests.post(url, data=data, headers=headers, timeout=TIMEOUT_SECONDS)
124+
except Exception:
125+
logger.exception("Error when making POST request")
126+
return None
127+
128+
return response

src/sentry/options/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,8 @@
602602

603603
# Codecov Integration
604604
register("codecov.client-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)
605+
register("codecov.base-url", default="https://api.codecov.io")
606+
register("codecov.api-bridge-signing-secret", flags=FLAG_CREDENTIAL | FLAG_PRIORITIZE_DISK)
605607

606608
# GitHub Integration
607609
register("github-app.id", default=0, flags=FLAG_AUTOMATOR_MODIFIABLE)

tests/sentry/codecov/test_client.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import datetime
2+
3+
import pytest
4+
import responses
5+
from unittest.mock import patch
6+
7+
from sentry.codecov.client import CodecovApiClient, ConfigurationError, GitProvider
8+
from sentry.testutils.cases import TestCase
9+
from sentry.testutils.silo import all_silo_test
10+
from sentry.utils import jwt
11+
12+
13+
@all_silo_test
14+
class TestCodecovApiClient(TestCase):
15+
def setUp(self):
16+
self.test_git_provider_user = "12345"
17+
self.test_base_url = "http://example.com"
18+
self.test_secret = "test-secret-" + "a" * 20
19+
20+
self.test_timestamp = datetime.datetime.now(datetime.UTC)
21+
self._mock_now = patch("datetime.datetime.now", return_value=self.test_timestamp)
22+
23+
with self.options({
24+
"codecov.base-url": self.test_base_url,
25+
"codecov.api-bridge-signing-secret": self.test_secret,
26+
}):
27+
self.codecov_client = CodecovApiClient(self.test_git_provider_user)
28+
29+
def test_raises_configuration_error_without_base_url(self):
30+
with self.options({
31+
"codecov.base-url": None,
32+
"codecov.api-bridge-signing-secret": self.test_secret,
33+
}):
34+
with pytest.raises(ConfigurationError):
35+
CodecovApiClient(self.test_git_provider_user)
36+
37+
def test_raises_configuration_error_without_signing_secret(self):
38+
with self.options({
39+
"codecov.base-url": self.test_base_url,
40+
"codecov.api-bridge-signing-secret": None,
41+
}):
42+
with pytest.raises(ConfigurationError):
43+
CodecovApiClient(self.test_git_provider_user)
44+
45+
def test_creates_valid_jwt(self):
46+
encoded_jwt = self.codecov_client._create_jwt()
47+
48+
header = jwt.peek_header(encoded_jwt)
49+
assert header == {
50+
"typ": "JWT",
51+
"alg": "HS256",
52+
}
53+
54+
# Ensure the claims are what we expect, separate from verifying the
55+
# signature and standard claims
56+
claims = jwt.peek_claims(encoded_jwt)
57+
expected_iat = int(self.test_timestamp.timestamp())
58+
expected_exp = expected_iat + 300
59+
assert claims == {
60+
"g_u": self.test_git_provider_user,
61+
"g_p": GitProvider.GitHub.value,
62+
"iss": "https://sentry.io",
63+
"iat": expected_iat,
64+
"exp": expected_exp,
65+
}
66+
67+
# Ensure we can verify the signature and whatall
68+
jwt.decode(encoded_jwt, self.test_secret)
69+
70+
@patch("requests.get")
71+
def test_sends_get_request_with_jwt_auth_header(self, mock_get):
72+
with patch.object(self.codecov_client, "_create_jwt", return_value="test"):
73+
self.codecov_client.get("/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"})
74+
mock_get.assert_called_once_with(
75+
"http://example.com/example/endpoint",
76+
params={"example-param": "foo"},
77+
headers={
78+
"Authorization": "Bearer test",
79+
"X_TEST_HEADER": "bar",
80+
},
81+
timeout=10,
82+
)
83+
84+
@patch("requests.post")
85+
def test_sends_post_request_with_jwt_auth_header(self, mock_post):
86+
with patch.object(self.codecov_client, "_create_jwt", return_value="test"):
87+
self.codecov_client.post("/example/endpoint", {"example-param": "foo"}, {"X_TEST_HEADER": "bar"})
88+
mock_post.assert_called_once_with(
89+
"http://example.com/example/endpoint",
90+
data={"example-param": "foo"},
91+
headers={
92+
"Authorization": "Bearer test",
93+
"X_TEST_HEADER": "bar",
94+
},
95+
timeout=10,
96+
)

0 commit comments

Comments
 (0)