Skip to content

Commit 24401c0

Browse files
committed
refactor: Refactor the rest and graphQL clients
1 parent 039457d commit 24401c0

File tree

9 files changed

+698
-329
lines changed

9 files changed

+698
-329
lines changed
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*-
2+
* -\-\-
3+
* github-api
4+
* --
5+
* Copyright (C) 2016 - 2020 Spotify AB
6+
* --
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* -/-/-
19+
*/
20+
package com.spotify.github.http;
21+
22+
import static okhttp3.MediaType.parse;
23+
24+
import com.spotify.github.Tracer;
25+
import com.spotify.github.jackson.Json;
26+
import com.spotify.github.v3.checks.AccessToken;
27+
import com.spotify.github.v3.clients.JwtTokenIssuer;
28+
import com.spotify.github.v3.clients.NoopTracer;
29+
import com.spotify.github.v3.exceptions.RequestNotOkException;
30+
import java.io.IOException;
31+
import java.lang.invoke.MethodHandles;
32+
import java.time.ZonedDateTime;
33+
import java.util.Map;
34+
import java.util.Objects;
35+
import java.util.concurrent.CompletableFuture;
36+
import java.util.concurrent.atomic.AtomicBoolean;
37+
import javax.ws.rs.core.MediaType;
38+
import okhttp3.*;
39+
import org.jetbrains.annotations.NotNull;
40+
import org.slf4j.Logger;
41+
import org.slf4j.LoggerFactory;
42+
43+
public abstract class AbstractGitHubApiClient {
44+
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
45+
46+
private static final int EXPIRY_MARGIN_IN_MINUTES = 5;
47+
48+
private static final String GET_ACCESS_TOKEN_URL = "app/installations/%s/access_tokens";
49+
50+
protected Tracer tracer = NoopTracer.INSTANCE;
51+
52+
protected abstract Map<Integer, AccessToken> installationTokens();
53+
54+
protected abstract GitHubClientConfig clientConfig();
55+
56+
protected abstract OkHttpClient client();
57+
58+
private static boolean isJwtRequest(final String path) {
59+
return path.startsWith("/app/installation") || path.endsWith("installation");
60+
}
61+
62+
/*
63+
Generates the Authentication header, given the API endpoint and the credentials provided.
64+
65+
<p>GitHub Requests can be authenticated in 3 different ways.
66+
(1) Regular, static access token;
67+
(2) JWT Token, generated from a private key. Used in Github Apps;
68+
(3) Installation Token, generated from the JWT token. Also used in Github Apps.
69+
*/
70+
public String getAuthorizationHeader(final String path) {
71+
var config = clientConfig();
72+
if (isJwtRequest(path) && config.privateKey().isEmpty()) {
73+
throw new IllegalStateException("This endpoint needs a client with a private key for an App");
74+
}
75+
if (config.accessToken().isPresent()) {
76+
return String.format("token %s", config.accessToken().get());
77+
} else if (config.privateKey().isPresent()) {
78+
final String jwtToken;
79+
try {
80+
jwtToken =
81+
JwtTokenIssuer.fromPrivateKey(config.privateKey().get()).getToken(config.appId().get());
82+
} catch (Exception e) {
83+
throw new RuntimeException("There was an error generating JWT token", e);
84+
}
85+
if (isJwtRequest(path)) {
86+
return String.format("Bearer %s", jwtToken);
87+
}
88+
if (config.installationId().isEmpty()) {
89+
throw new RuntimeException("This endpoint needs a client with an installation ID");
90+
}
91+
try {
92+
return String.format(
93+
"token %s", getInstallationToken(jwtToken, config.installationId().get()));
94+
} catch (Exception e) {
95+
throw new RuntimeException("Could not generate access token for github app", e);
96+
}
97+
}
98+
throw new RuntimeException("Not possible to authenticate. ");
99+
}
100+
101+
private boolean isExpired(final AccessToken token) {
102+
// Adds a few minutes to avoid making calls with an expired token due to clock differences
103+
return token.expiresAt().isBefore(ZonedDateTime.now().plusMinutes(EXPIRY_MARGIN_IN_MINUTES));
104+
}
105+
106+
private String getInstallationToken(final String jwtToken, final int installationId)
107+
throws Exception {
108+
109+
AccessToken installationToken = installationTokens().get(installationId);
110+
111+
if (installationToken == null || isExpired(installationToken)) {
112+
log.info(
113+
"Github token for installation {} is either expired or null. Trying to get a new one.",
114+
installationId);
115+
installationToken = generateInstallationToken(jwtToken, installationId);
116+
installationTokens().put(installationId, installationToken);
117+
}
118+
return installationToken.token();
119+
}
120+
121+
/**
122+
* Create a URL for a given path to this Github server.
123+
*
124+
* @param path relative URI
125+
* @return URL to path on this server
126+
*/
127+
String urlFor(final String path) {
128+
return clientConfig().baseUrl().toString().replaceAll("/+$", "")
129+
+ "/"
130+
+ path.replaceAll("^/+", "");
131+
}
132+
133+
private AccessToken generateInstallationToken(final String jwtToken, final int installationId)
134+
throws Exception {
135+
log.info("Got JWT Token. Now getting Github access_token for installation {}", installationId);
136+
final String url = String.format(urlFor(GET_ACCESS_TOKEN_URL), installationId);
137+
final Request request =
138+
new Request.Builder()
139+
.addHeader("Accept", "application/vnd.github.machine-man-preview+json")
140+
.addHeader("Authorization", "Bearer " + jwtToken)
141+
.url(url)
142+
.method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), ""))
143+
.build();
144+
145+
final Response response = client().newCall(request).execute();
146+
147+
if (!response.isSuccessful()) {
148+
throw new Exception(
149+
String.format(
150+
"Got non-2xx status %s when getting an access token from GitHub: %s",
151+
response.code(), response.message()));
152+
}
153+
154+
if (response.body() == null) {
155+
throw new Exception(
156+
String.format(
157+
"Got empty response body when getting an access token from GitHub, HTTP status was: %s",
158+
response.message()));
159+
}
160+
final String text = response.body().string();
161+
response.body().close();
162+
return Json.create().fromJson(text, AccessToken.class);
163+
}
164+
165+
protected abstract RequestNotOkException mapException(Response res, Request request)
166+
throws IOException;
167+
168+
protected abstract CompletableFuture<Response> processPossibleRedirects(
169+
Response response, AtomicBoolean redirected);
170+
171+
protected CompletableFuture<Response> call(final Request request) {
172+
final Call call = client().newCall(request);
173+
174+
final CompletableFuture<Response> future = new CompletableFuture<>();
175+
176+
// avoid multiple redirects
177+
final AtomicBoolean redirected = new AtomicBoolean(false);
178+
179+
call.enqueue(
180+
new Callback() {
181+
@Override
182+
public void onFailure(@NotNull final Call call, final IOException e) {
183+
future.completeExceptionally(e);
184+
}
185+
186+
@Override
187+
public void onResponse(@NotNull final Call call, final Response response) {
188+
processPossibleRedirects(response, redirected)
189+
.handle(
190+
(res, ex) -> {
191+
if (Objects.nonNull(ex)) {
192+
future.completeExceptionally(ex);
193+
} else if (!res.isSuccessful()) {
194+
try {
195+
future.completeExceptionally(mapException(res, request));
196+
} catch (final Throwable e) {
197+
future.completeExceptionally(e);
198+
} finally {
199+
if (res.body() != null) {
200+
res.body().close();
201+
}
202+
}
203+
} else {
204+
future.complete(res);
205+
}
206+
return res;
207+
});
208+
}
209+
});
210+
tracer.span(request.url().toString(), request.method(), future);
211+
return future;
212+
}
213+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*-
2+
* -\-\-
3+
* github-api
4+
* --
5+
* Copyright (C) 2016 - 2020 Spotify AB
6+
* --
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* -/-/-
19+
*/
20+
package com.spotify.github.http;
21+
22+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
23+
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
24+
import com.spotify.github.GithubStyle;
25+
import java.net.URI;
26+
import java.util.Optional;
27+
import okhttp3.OkHttpClient;
28+
import org.immutables.value.Value;
29+
30+
@Value.Immutable
31+
@GithubStyle
32+
@JsonSerialize(as = ImmutableGitHubClientConfig.class)
33+
@JsonDeserialize(as = ImmutableGitHubClientConfig.class)
34+
public interface GitHubClientConfig {
35+
OkHttpClient client();
36+
37+
Optional<URI> baseUrl();
38+
39+
Optional<URI> graphqlApiUrl();
40+
41+
Optional<String> accessToken();
42+
43+
Optional<byte[]> privateKey();
44+
45+
Optional<Integer> appId();
46+
47+
Optional<Integer> installationId();
48+
}

0 commit comments

Comments
 (0)