requestBuilder(final String path) {
+ return getAuthorizationHeader(path)
+ .thenApply(
+ authHeader ->
+ ImmutableHttpRequest.builder()
+ .url(urlFor(path))
+ .method("GET")
+ .body("")
+ .putHeaders(HttpHeaders.ACCEPT, List.of(MediaType.APPLICATION_JSON))
+ .putHeaders(HttpHeaders.CONTENT_TYPE, List.of(MediaType.APPLICATION_JSON))
+ .putHeaders(HttpHeaders.AUTHORIZATION, List.of(authHeader)));
}
+ /*
+ * Check if the GraphQL API is enabled for this client.
+ *
+ * @return true if the GraphQL API is enabled, false otherwise
+ */
public boolean isGraphqlEnabled() {
return graphqlUrl.isPresent();
}
-
/*
Generates the Authentication header, given the API endpoint and the credentials provided.
- Github Requests can be authenticated in 3 different ways.
+
GitHub Requests can be authenticated in 3 different ways.
(1) Regular, static access token;
- (2) JWT Token, generated from a private key. Used in Github Apps;
- (3) Installation Token, generated from the JWT token. Also used in Github Apps.
+ (2) JWT Token, generated from a private key. Used in GitHub Apps;
+ (3) Installation Token, generated from the JWT token. Also used in GitHub Apps.
*/
- private String getAuthorizationHeader(final String path) {
+ private CompletableFuture getAuthorizationHeader(final String path) {
if (isJwtRequest(path) && getPrivateKey().isEmpty()) {
throw new IllegalStateException("This endpoint needs a client with a private key for an App");
}
if (getAccessToken().isPresent()) {
- return String.format("token %s", token);
+ return completedFuture(String.format("token %s", token));
} else if (getPrivateKey().isPresent()) {
final String jwtToken;
try {
@@ -793,13 +1006,18 @@ private String getAuthorizationHeader(final String path) {
throw new RuntimeException("There was an error generating JWT token", e);
}
if (isJwtRequest(path)) {
- return String.format("Bearer %s", jwtToken);
+ return completedFuture(String.format("Bearer %s", jwtToken));
}
if (installationId == null) {
throw new RuntimeException("This endpoint needs a client with an installation ID");
}
try {
- return String.format("token %s", getInstallationToken(jwtToken, installationId));
+ return getInstallationToken(jwtToken, installationId)
+ .thenApply(token -> String.format("token %s", token))
+ .exceptionally(
+ ex -> {
+ throw new RuntimeException("Could not generate access token for github app", ex);
+ });
} catch (Exception e) {
throw new RuntimeException("Could not generate access token for github app", e);
}
@@ -808,144 +1026,237 @@ private String getAuthorizationHeader(final String path) {
}
private boolean isJwtRequest(final String path) {
- return path.startsWith("/app/installation") || path.endsWith("installation");
+ return path.equals("/app")
+ || path.startsWith("/app/installation")
+ || path.endsWith("installation");
}
- private String getInstallationToken(final String jwtToken, final int installationId)
- throws Exception {
+ /**
+ * Fetches installation token from the cache or from the server if it is expired.
+ *
+ * @param jwtToken the JWT token
+ * @param installationId the installation ID
+ * @return a CompletableFuture with the installation token
+ */
+ private CompletableFuture getInstallationToken(
+ final String jwtToken, final int installationId) {
AccessToken installationToken = installationTokens.get(installationId);
if (installationToken == null || isExpired(installationToken)) {
log.info(
- "Github token for installation {} is either expired or null. Trying to get a new one.",
+ "GitHub token for installation {} is either expired or null. Trying to get a new one.",
installationId);
- installationToken = generateInstallationToken(jwtToken, installationId);
- installationTokens.put(installationId, installationToken);
+ return generateInstallationToken(jwtToken, installationId)
+ .thenApply(
+ accessToken -> {
+ installationTokens.put(installationId, accessToken);
+ return accessToken.token();
+ });
}
- return installationToken.token();
+ return completedFuture(installationToken.token());
}
+ /**
+ * Check if the token is expired.
+ *
+ * @param token the access token
+ * @return true if the token is expired, false otherwise
+ */
private boolean isExpired(final AccessToken token) {
// Adds a few minutes to avoid making calls with an expired token due to clock differences
return token.expiresAt().isBefore(ZonedDateTime.now().plusMinutes(EXPIRY_MARGIN_IN_MINUTES));
}
- private AccessToken generateInstallationToken(final String jwtToken, final int installationId)
- throws Exception {
- log.info("Got JWT Token. Now getting Github access_token for installation {}", installationId);
+ /**
+ * Generates the installation token for a given installation ID.
+ *
+ * @param jwtToken the JWT token
+ * @param installationId the installation ID
+ * @return a CompletableFuture with the access token
+ */
+ private CompletableFuture generateInstallationToken(
+ final String jwtToken, final int installationId) {
+ log.info("Got JWT Token. Now getting GitHub access_token for installation {}", installationId);
final String url = String.format(urlFor(GET_ACCESS_TOKEN_URL), installationId);
- final Request request =
- new Request.Builder()
- .addHeader("Accept", "application/vnd.github.machine-man-preview+json")
- .addHeader("Authorization", "Bearer " + jwtToken)
+ final HttpRequest request =
+ ImmutableHttpRequest.builder()
.url(url)
- .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), ""))
+ .putHeaders("Accept", List.of("application/vnd.github.machine-man-preview+json"))
+ .putHeaders("Authorization", List.of("Bearer " + jwtToken))
+ .method("POST")
+ .body("")
.build();
- final Response response = client.newCall(request).execute();
-
- if (!response.isSuccessful()) {
- throw new Exception(
- String.format(
- "Got non-2xx status %s when getting an access token from GitHub: %s",
- response.code(), response.message()));
- }
-
- if (response.body() == null) {
- throw new Exception(
- String.format(
- "Got empty response body when getting an access token from GitHub, HTTP status was: %s",
- response.message()));
- }
- final String text = response.body().string();
- response.body().close();
- return Json.create().fromJson(text, AccessToken.class);
+ return this.client
+ .send(request)
+ .thenApply(
+ response -> {
+ if (!response.isSuccessful()) {
+ throw new RuntimeException(
+ String.format(
+ "Got non-2xx status %s when getting an access token from GitHub: %s",
+ response.statusCode(), response.statusMessage()));
+ }
+
+ if (response.bodyString() == null) {
+ throw new RuntimeException(
+ String.format(
+ "Got empty response body when getting an access token from GitHub, HTTP"
+ + " status was: %s",
+ response.statusMessage()));
+ }
+ final String text = response.bodyString();
+ try {
+ return Json.create().fromJson(text, AccessToken.class);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .toCompletableFuture();
}
- private CompletableFuture call(final Request request) {
- final Call call = client.newCall(request);
-
- final CompletableFuture future = new CompletableFuture<>();
+ private CompletableFuture call(final HttpRequest httpRequest) {
+ return this.client
+ .send(httpRequest)
+ .thenCompose(httpResponse -> handleResponse(httpRequest, httpResponse));
+ }
+ /**
+ * Handle the response from the server. If the response is a redirect, redo the request with the
+ * new URL.
+ *
+ * @param httpRequest the original request
+ * @param httpResponse the response from the server
+ * @return a CompletableFuture with the processed response
+ */
+ private CompletableFuture handleResponse(
+ final HttpRequest httpRequest, final HttpResponse httpResponse) {
+ final CompletableFuture future = new CompletableFuture<>();
// avoid multiple redirects
final AtomicBoolean redirected = new AtomicBoolean(false);
-
- call.enqueue(
- new Callback() {
- @Override
- public void onFailure(final Call call, final IOException e) {
- future.completeExceptionally(e);
- }
-
- @Override
- public void onResponse(final Call call, final Response response) {
- processPossibleRedirects(response, redirected)
- .handle(
- (res, ex) -> {
- if (Objects.nonNull(ex)) {
- future.completeExceptionally(ex);
- } else if (!res.isSuccessful()) {
- try {
- future.completeExceptionally(mapException(res, request));
- } catch (final Throwable e) {
- future.completeExceptionally(e);
- } finally {
- if (res.body() != null) {
- res.body().close();
- }
- }
- } else {
- future.complete(res);
- }
- return res;
- });
- }
- });
- tracer.span(request.url().toString(), request.method(), future);
+ processPossibleRedirects(httpResponse, redirected)
+ .handle(
+ (res, ex) -> {
+ if (Objects.nonNull(ex)) {
+ future.completeExceptionally(ex);
+ } else if (!res.isSuccessful()) {
+ try {
+ future.completeExceptionally(mapException(httpRequest, res));
+ } catch (final Throwable e) {
+ future.completeExceptionally(e);
+ }
+ } else {
+ future.complete(res);
+ }
+ return res;
+ })
+ .join();
return future;
}
- private RequestNotOkException mapException(final Response res, final Request request)
- throws IOException {
- String bodyString = res.body() != null ? res.body().string() : "";
- Map> headersMap = res.headers().toMultimap();
+ /**
+ * Map the exception to a specific type based on the response status code.
+ *
+ * @param httpRequest the original request
+ * @param httpResponse the response from the server
+ * @return a RequestNotOkException with the appropriate type
+ */
+ private RequestNotOkException mapException(
+ final HttpRequest httpRequest, final HttpResponse httpResponse) throws IOException {
+ String bodyString = Optional.ofNullable(httpResponse.bodyString()).orElse("");
+ Map> headersMap = httpResponse.headers();
- if (res.code() == FORBIDDEN) {
+ if (httpResponse.statusCode() == FORBIDDEN) {
if (bodyString.contains("Repository was archived so is read-only")) {
- return new ReadOnlyRepositoryException(request.method(), request.url().encodedPath(), res.code(), bodyString, headersMap);
+ return new ReadOnlyRepositoryException(
+ httpRequest.method(),
+ URI.create(httpRequest.url()).getPath(),
+ httpResponse.statusCode(),
+ bodyString,
+ headersMap);
}
}
- return new RequestNotOkException(request.method(), request.url().encodedPath(), res.code(), bodyString, headersMap);
+ return new RequestNotOkException(
+ httpRequest.method(),
+ URI.create(httpRequest.url()).getPath(),
+ httpResponse.statusCode(),
+ bodyString,
+ headersMap);
}
- CompletableFuture processPossibleRedirects(
- final Response response, final AtomicBoolean redirected) {
- if (response.code() >= PERMANENT_REDIRECT
- && response.code() <= TEMPORARY_REDIRECT
+ /**
+ * Process possible redirects. If the response is a redirect, redo the request with the new URL.
+ *
+ * @param response the response to process
+ * @param redirected a flag to indicate if a redirect has already occurred
+ * @return a CompletableFuture with the processed response
+ */
+ CompletableFuture processPossibleRedirects(
+ final HttpResponse response, final AtomicBoolean redirected) {
+ if (response.statusCode() >= PERMANENT_REDIRECT
+ && response.statusCode() <= TEMPORARY_REDIRECT
&& !redirected.get()) {
redirected.set(true);
// redo the same request with a new URL
- final String newLocation = response.header("Location");
- final Request request =
- requestBuilder(newLocation)
- .url(newLocation)
- .method(response.request().method(), response.request().body())
- .build();
- // Do the new call and complete the original future when the new call completes
- return call(request);
+ final String newLocation = response.headers().get("Location").get(0);
+ return requestBuilder(newLocation)
+ .thenCompose(
+ requestBuilder -> {
+ HttpRequest request =
+ requestBuilder
+ .url(newLocation)
+ .method(response.request().method())
+ .body(response.request().body())
+ .build();
+ // Do the new call and complete the original future when the new call completes
+ return call(request);
+ });
}
return completedFuture(response);
}
- /**
- * Wrapper to Constructors that expose File object for the privateKey argument
- * */
- private static GitHubClient createOrThrow(final OkHttpClient httpClient, final URI baseUrl, final URI graphqlUrl, final File privateKey, final Integer appId, final Integer installationId) {
+ /** Wrapper to Constructors that expose File object for the privateKey argument */
+ private static GitHubClient createOrThrow(
+ final OkHttpClient httpClient,
+ final URI baseUrl,
+ final URI graphqlUrl,
+ final File privateKey,
+ final Integer appId,
+ final Integer installationId) {
try {
- return new GitHubClient(httpClient, baseUrl, graphqlUrl, null, FileUtils.readFileToByteArray(privateKey), appId, installationId);
+ return new GitHubClient(
+ httpClient,
+ baseUrl,
+ graphqlUrl,
+ null,
+ FileUtils.readFileToByteArray(privateKey),
+ appId,
+ installationId);
+ } catch (IOException e) {
+ throw new RuntimeException("There was an error generating JWT token", e);
+ }
+ }
+
+ /** Wrapper to Constructors that expose File object for the privateKey argument */
+ private static GitHubClient createOrThrow(
+ final HttpClient httpClient,
+ final URI baseUrl,
+ final URI graphqlUrl,
+ final File privateKey,
+ final Integer appId,
+ final Integer installationId) {
+ try {
+ return new GitHubClient(
+ httpClient,
+ baseUrl,
+ graphqlUrl,
+ null,
+ FileUtils.readFileToByteArray(privateKey),
+ appId,
+ installationId);
} catch (IOException e) {
throw new RuntimeException("There was an error generating JWT token", e);
}
diff --git a/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java b/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java
index 02845801..aeb96094 100644
--- a/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java
+++ b/src/main/java/com/spotify/github/v3/clients/GithubAppClient.java
@@ -7,9 +7,9 @@
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -23,7 +23,9 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.common.collect.ImmutableMap;
import com.spotify.github.v3.apps.InstallationRepositoriesResponse;
+import com.spotify.github.v3.apps.requests.AccessTokenRequest;
import com.spotify.github.v3.checks.AccessToken;
+import com.spotify.github.v3.checks.App;
import com.spotify.github.v3.checks.Installation;
import java.util.List;
import java.util.Map;
@@ -34,6 +36,7 @@
/** Apps API client */
public class GithubAppClient {
+ private static final String GET_AUTHENTICATED_APP_URL = "/app";
private static final String GET_INSTALLATION_BY_ID_URL = "/app/installations/%s";
private static final String GET_ACCESS_TOKEN_URL = "/app/installations/%s/access_tokens";
private static final String GET_INSTALLATIONS_URL = "/app/installations?per_page=100";
@@ -48,7 +51,7 @@ public class GithubAppClient {
private static final String GET_INSTALLATION_USER_URL = "/users/%s/installation";
private final GitHubClient github;
- private final String owner;
+ private final Optional maybeOwner;
private final Optional maybeRepo;
private final Map extraHeaders =
@@ -59,16 +62,38 @@ public class GithubAppClient {
GithubAppClient(final GitHubClient github, final String owner, final String repo) {
this.github = github;
- this.owner = owner;
+ this.maybeOwner = Optional.of(owner);
this.maybeRepo = Optional.of(repo);
}
GithubAppClient(final GitHubClient github, final String owner) {
this.github = github;
- this.owner = owner;
+ this.maybeOwner = Optional.of(owner);
this.maybeRepo = Optional.empty();
}
+ GithubAppClient(final GitHubClient github) {
+ this.github = github;
+ this.maybeOwner = Optional.empty();
+ this.maybeRepo = Optional.empty();
+ }
+
+ /**
+ * Gets the owner, throwing a descriptive exception if not present.
+ *
+ * @return the owner string
+ * @throws IllegalStateException if owner is not present
+ */
+ private String requireOwner() {
+ return maybeOwner.orElseThrow(
+ () ->
+ new IllegalStateException(
+ "This operation requires an owner context. "
+ + "Use GitHubClient.createOrganisationClient(owner).createGithubAppClient() "
+ + "or GitHubClient.createRepositoryClient(owner, repo).createGithubAppClient() "
+ + "instead of GitHubClient.createGithubAppClient()"));
+ }
+
/**
* List Installations of an app.
*
@@ -79,7 +104,7 @@ public CompletableFuture> getInstallations() {
}
/**
- * Get Installation
+ * Get Installation of repo or org
*
* @return an Installation
*/
@@ -99,41 +124,60 @@ public CompletableFuture getInstallation(final Integer installatio
/**
* Get an installation of a repo
+ *
* @return an Installation
*/
private CompletableFuture getRepoInstallation(final String repo) {
return github.request(
- String.format(GET_INSTALLATION_REPO_URL, owner, repo), Installation.class);
+ String.format(GET_INSTALLATION_REPO_URL, requireOwner(), repo), Installation.class);
}
/**
* Get an installation of an org
+ *
* @return an Installation
*/
private CompletableFuture getOrgInstallation() {
return github.request(
- String.format(GET_INSTALLATION_ORG_URL, owner), Installation.class);
+ String.format(GET_INSTALLATION_ORG_URL, requireOwner()), Installation.class);
}
- /**
+ /**
* Get an installation of a user
+ *
* @return an Installation
*/
public CompletableFuture getUserInstallation() {
return github.request(
- String.format(GET_INSTALLATION_USER_URL, owner), Installation.class);
+ String.format(GET_INSTALLATION_USER_URL, requireOwner()), Installation.class);
}
/**
* Authenticates as an installation
*
* @return an Installation Token
+ * @see #getAccessToken(Integer, AccessTokenRequest) for repository-scoped tokens
*/
public CompletableFuture getAccessToken(final Integer installationId) {
final String path = String.format(GET_ACCESS_TOKEN_URL, installationId);
return github.post(path, "", AccessToken.class, extraHeaders);
}
+ /**
+ * Authenticates as an installation with repository scoping.
+ *
+ * @param installationId the installation ID
+ * @param request the access token request with optional repository scoping
+ * @return an Installation Token
+ * @see "https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app"
+ */
+ public CompletableFuture getAccessToken(
+ final Integer installationId,
+ final AccessTokenRequest request) {
+ final String path = String.format(GET_ACCESS_TOKEN_URL, installationId);
+ return github.post(path, github.json().toJsonUnchecked(request), AccessToken.class, extraHeaders);
+ }
+
/**
* Lists the repositories that an app installation can access.
*
@@ -146,4 +190,17 @@ public CompletableFuture listAccessibleReposit
return GitHubClient.scopeForInstallationId(github, installationId)
.request(LIST_ACCESSIBLE_REPOS_URL, InstallationRepositoriesResponse.class, extraHeaders);
}
+
+ /**
+ * Get the authenticated GitHub App.
+ *
+ * Returns the authenticated app. You must use a JWT to access this endpoint.
+ *
+ *
see https://docs.github.com/en/rest/apps/apps#get-the-authenticated-app
+ *
+ * @return the authenticated App
+ */
+ public CompletableFuture getAuthenticatedApp() {
+ return github.request(GET_AUTHENTICATED_APP_URL, App.class);
+ }
}
diff --git a/src/main/java/com/spotify/github/v3/clients/GithubPage.java b/src/main/java/com/spotify/github/v3/clients/GithubPage.java
index 6bbd4ad0..3e9e6c3a 100644
--- a/src/main/java/com/spotify/github/v3/clients/GithubPage.java
+++ b/src/main/java/com/spotify/github/v3/clients/GithubPage.java
@@ -7,9 +7,9 @@
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
- *
+ *
* http://www.apache.org/licenses/LICENSE-2.0
- *
+ *
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -20,7 +20,6 @@
package com.spotify.github.v3.clients;
-import static com.spotify.github.v3.clients.GitHubClient.responseBodyUnchecked;
import static java.util.Arrays.stream;
import static java.util.Objects.nonNull;
import static java.util.function.Function.identity;
@@ -37,8 +36,9 @@
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
-import java.util.stream.Stream;
-import okhttp3.ResponseBody;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.http.client.utils.URIBuilder;
/**
* Async page implementation for github resources
@@ -47,12 +47,27 @@
*/
public class GithubPage implements AsyncPage {
+ static final int ITEM_PER_PAGE_DEFAULT = 30;
private final GitHubClient github;
private final String path;
private final TypeReference> typeReference;
+ private final int itemsPerPage;
+
+ protected static String formatPath(final String path, final int itemsPerPage) {
+ try {
+ URIBuilder uriBuilder = new URIBuilder(path);
+ if (uriBuilder.getQueryParams().stream().anyMatch(p -> p.getName().equals("per_page"))) {
+ return path;
+ }
+ uriBuilder.addParameter("per_page", Integer.toString(itemsPerPage));
+ return uriBuilder.toString();
+ } catch (Exception e) {
+ return path;
+ }
+ }
/**
- * C'tor.
+ * Constructor.
*
* @param github github client
* @param path resource page path
@@ -60,8 +75,27 @@ public class GithubPage implements AsyncPage {
*/
GithubPage(
final GitHubClient github, final String path, final TypeReference> typeReference) {
+ this.itemsPerPage = ITEM_PER_PAGE_DEFAULT;
+ this.github = github;
+ this.path = formatPath(path, ITEM_PER_PAGE_DEFAULT);
+ this.typeReference = typeReference;
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param github github client
+ * @param path resource page path
+ * @param typeReference type reference for deserialization
+ */
+ GithubPage(
+ final GitHubClient github,
+ final String path,
+ final TypeReference> typeReference,
+ final int itemsPerPage) {
+ this.itemsPerPage = itemsPerPage;
this.github = github;
- this.path = path;
+ this.path = formatPath(path, itemsPerPage);
this.typeReference = typeReference;
}
@@ -80,7 +114,7 @@ public CompletableFuture pagination() {
.map(
prevLink ->
pageNumberFromUri(prevLink.url().toString())
- .orElseThrow(
+ .orElseThrow(
() ->
new RuntimeException(
"Could not parse page number from Link header with rel=\"next\"")));
@@ -94,7 +128,7 @@ public CompletableFuture pagination() {
.map(
lastLink ->
pageNumberFromUri(lastLink.url().toString())
- .orElseThrow(
+ .orElseThrow(
() ->
new RuntimeException(
"Could not parse page number from Link "
@@ -124,7 +158,7 @@ public CompletableFuture> nextPage() {
Optional.ofNullable(linkMap.get("next"))
.map(nextLink -> nextLink.url().toString().replaceAll(github.urlFor(""), ""))
.orElseThrow(() -> new NoSuchElementException("Page iteration exhausted"));
- return new GithubPage<>(github, nextPath, typeReference);
+ return new GithubPage<>(github, nextPath, typeReference, itemsPerPage);
});
}
@@ -137,7 +171,7 @@ public CompletableFuture hasNextPage() {
/** {@inheritDoc} */
@Override
public AsyncPage clone() {
- return new GithubPage<>(github, path, typeReference);
+ return new GithubPage<>(github, path, typeReference, itemsPerPage);
}
/** {@inheritDoc} */
@@ -147,9 +181,7 @@ public Iterator iterator() {
.request(path)
.thenApply(
response ->
- github
- .json()
- .fromJsonUncheckedNotNull(responseBodyUnchecked(response), typeReference))
+ github.json().fromJsonUncheckedNotNull(response.bodyString(), typeReference))
.join()
.iterator();
}
@@ -158,20 +190,22 @@ private CompletableFuture