All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.spotify.github.v3.clients.GitHubClient Maven / Gradle / Ivy

The newest version!
/*-
 * -\-\-
 * github-api
 * --
 * Copyright (C) 2016 - 2020 Spotify AB
 * --
 * 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.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * -/-/-
 */

package com.spotify.github.v3.clients;

import static java.util.concurrent.CompletableFuture.completedFuture;

import com.fasterxml.jackson.core.type.TypeReference;
import com.spotify.github.async.Async;
import com.spotify.github.http.HttpClient;
import com.spotify.github.http.HttpRequest;
import com.spotify.github.http.HttpResponse;
import com.spotify.github.http.ImmutableHttpRequest;
import com.spotify.github.http.okhttp.OkHttpHttpClient;
import com.spotify.github.jackson.Json;
import com.spotify.github.tracing.NoopTracer;
import com.spotify.github.tracing.Tracer;
import com.spotify.github.v3.Team;
import com.spotify.github.v3.User;
import com.spotify.github.v3.checks.AccessToken;
import com.spotify.github.v3.checks.Installation;
import com.spotify.github.v3.comment.Comment;
import com.spotify.github.v3.comment.CommentReaction;
import com.spotify.github.v3.exceptions.ReadOnlyRepositoryException;
import com.spotify.github.v3.exceptions.RequestNotOkException;
import com.spotify.github.v3.git.FileItem;
import com.spotify.github.v3.git.Reference;
import com.spotify.github.v3.orgs.TeamInvitation;
import com.spotify.github.v3.prs.PullRequestItem;
import com.spotify.github.v3.prs.Review;
import com.spotify.github.v3.prs.ReviewRequests;
import com.spotify.github.v3.repos.*;
import java.io.File;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.net.URI;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import okhttp3.*;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * GitHub client is a main communication entry point. Provides lower level communication
 * functionality as well as acts as a factory for the higher level API clients.
 */
public class GitHubClient {

  private static final int EXPIRY_MARGIN_IN_MINUTES = 5;
  private static final int HTTP_NOT_FOUND = 404;

  private Tracer tracer = NoopTracer.INSTANCE;

  static final Consumer IGNORE_RESPONSE_CONSUMER =
      (response) -> {
        if (response != null) {
          response.close();
        }
      };
  static final TypeReference> LIST_COMMENT_TYPE_REFERENCE = new TypeReference<>() {};
  static final TypeReference> LIST_COMMENT_REACTION_TYPE_REFERENCE =
      new TypeReference<>() {};
  static final TypeReference> LIST_REPOSITORY = new TypeReference<>() {};
  static final TypeReference> LIST_COMMIT_TYPE_REFERENCE =
      new TypeReference<>() {};
  static final TypeReference> LIST_REVIEW_TYPE_REFERENCE = new TypeReference<>() {};
  static final TypeReference LIST_REVIEW_REQUEST_TYPE_REFERENCE =
      new TypeReference<>() {};
  static final TypeReference> LIST_STATUS_TYPE_REFERENCE = new TypeReference<>() {};
  static final TypeReference> LIST_FOLDERCONTENT_TYPE_REFERENCE =
      new TypeReference<>() {};
  static final TypeReference> LIST_PR_TYPE_REFERENCE =
      new TypeReference<>() {};
  static final TypeReference>
      LIST_PR_COMMENT_TYPE_REFERENCE = new TypeReference<>() {};
  static final TypeReference> LIST_BRANCHES = new TypeReference<>() {};
  static final TypeReference> LIST_REFERENCES = new TypeReference<>() {};
  static final TypeReference> LIST_REPOSITORY_INVITATION =
      new TypeReference<>() {};

  static final TypeReference> LIST_TEAMS = new TypeReference<>() {};

  static final TypeReference> LIST_TEAM_MEMBERS = new TypeReference<>() {};

  static final TypeReference> LIST_PENDING_TEAM_INVITATIONS =
      new TypeReference<>() {};

  static final TypeReference> LIST_FILE_ITEMS = new TypeReference<>() {};

  private static final String GET_ACCESS_TOKEN_URL = "app/installations/%s/access_tokens";

  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  private static final int PERMANENT_REDIRECT = 301;
  private static final int TEMPORARY_REDIRECT = 307;
  private static final int FORBIDDEN = 403;

  private final URI baseUrl;

  private final Optional graphqlUrl;
  private final Json json = Json.create();
  private final HttpClient client;
  private Call.Factory callFactory;
  private final String token;

  private final byte[] privateKey;
  private final Integer appId;
  private final Integer installationId;

  private final Map installationTokens;

  private GitHubClient(
      final HttpClient client,
      final URI baseUrl,
      final URI graphqlUrl,
      final String accessToken,
      final byte[] privateKey,
      final Integer appId,
      final Integer installationId) {
    this.baseUrl = baseUrl;
    this.graphqlUrl = Optional.ofNullable(graphqlUrl);
    this.token = accessToken;
    this.client = client;
    this.privateKey = privateKey;
    this.appId = appId;
    this.installationId = installationId;
    this.installationTokens = new ConcurrentHashMap<>();
  }

  private GitHubClient(
      final OkHttpClient client,
      final URI baseUrl,
      final URI graphqlUrl,
      final String accessToken,
      final byte[] privateKey,
      final Integer appId,
      final Integer installationId) {
    this.baseUrl = baseUrl;
    this.graphqlUrl = Optional.ofNullable(graphqlUrl);
    this.token = accessToken;
    this.client = new OkHttpHttpClient(client);
    this.privateKey = privateKey;
    this.appId = appId;
    this.installationId = installationId;
    this.installationTokens = new ConcurrentHashMap<>();
  }

  /**
   * Create a github api client with a given base URL and authorization token.
   *
   * @param baseUrl base URL
   * @param token authorization token
   * @return github api client
   */
  public static GitHubClient create(final URI baseUrl, final String token) {
    return new GitHubClient(new OkHttpClient(), baseUrl, null, token, null, null, null);
  }

  public static GitHubClient create(final URI baseUrl, final URI graphqlUri, final String token) {
    return new GitHubClient(new OkHttpClient(), baseUrl, graphqlUri, token, null, null, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(final URI baseUrl, final File privateKey, final Integer appId) {
    return createOrThrow(new OkHttpClient(), baseUrl, null, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param baseUrl base URL
   * @param privateKey the private key as byte array
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final URI baseUrl, final byte[] privateKey, final Integer appId) {
    return new GitHubClient(new OkHttpClient(), baseUrl, null, null, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @param installationId the installationID to be authenticated as
   * @return github api client
   */
  public static GitHubClient create(
      final URI baseUrl, final File privateKey, final Integer appId, final Integer installationId) {
    return createOrThrow(new OkHttpClient(), baseUrl, null, privateKey, appId, installationId);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param baseUrl base URL
   * @param privateKey the private key as byte array
   * @param appId the github app ID
   * @param installationId the installationID to be authenticated as
   * @return github api client
   */
  public static GitHubClient create(
      final URI baseUrl,
      final byte[] privateKey,
      final Integer appId,
      final Integer installationId) {
    return new GitHubClient(
        new OkHttpClient(), baseUrl, null, null, privateKey, appId, installationId);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final OkHttpClient httpClient,
      final URI baseUrl,
      final File privateKey,
      final Integer appId) {
    return createOrThrow(httpClient, baseUrl, null, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final OkHttpClient httpClient,
      final URI baseUrl,
      final URI graphqlUrl,
      final File privateKey,
      final Integer appId) {
    return createOrThrow(httpClient, baseUrl, graphqlUrl, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key as byte array
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final OkHttpClient httpClient,
      final URI baseUrl,
      final byte[] privateKey,
      final Integer appId) {
    return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final OkHttpClient httpClient,
      final URI baseUrl,
      final File privateKey,
      final Integer appId,
      final Integer installationId) {
    return createOrThrow(httpClient, baseUrl, null, privateKey, appId, installationId);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key as byte array
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final OkHttpClient httpClient,
      final URI baseUrl,
      final byte[] privateKey,
      final Integer appId,
      final Integer installationId) {
    return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, installationId);
  }

  /**
   * Create a github api client with a given base URL and authorization token.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param token authorization token
   * @return github api client
   */
  public static GitHubClient create(
      final OkHttpClient httpClient, final URI baseUrl, final String token) {
    return new GitHubClient(httpClient, baseUrl, null, token, null, null, null);
  }

  public static GitHubClient create(
      final OkHttpClient httpClient, final URI baseUrl, final URI graphqlUrl, final String token) {
    return new GitHubClient(httpClient, baseUrl, graphqlUrl, token, null, null, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final HttpClient httpClient, final URI baseUrl, final File privateKey, final Integer appId) {
    return createOrThrow(httpClient, baseUrl, null, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final HttpClient httpClient,
      final URI baseUrl,
      final URI graphqlUrl,
      final File privateKey,
      final Integer appId) {
    return createOrThrow(httpClient, baseUrl, graphqlUrl, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key as byte array
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final HttpClient httpClient,
      final URI baseUrl,
      final byte[] privateKey,
      final Integer appId) {
    return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, null);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key PEM file
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final HttpClient httpClient,
      final URI baseUrl,
      final File privateKey,
      final Integer appId,
      final Integer installationId) {
    return createOrThrow(httpClient, baseUrl, null, privateKey, appId, installationId);
  }

  /**
   * Create a github api client with a given base URL and a path to a key.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param privateKey the private key as byte array
   * @param appId the github app ID
   * @return github api client
   */
  public static GitHubClient create(
      final HttpClient httpClient,
      final URI baseUrl,
      final byte[] privateKey,
      final Integer appId,
      final Integer installationId) {
    return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, installationId);
  }

  /**
   * Create a github api client with a given base URL and authorization token.
   *
   * @param httpClient an instance of OkHttpClient
   * @param baseUrl base URL
   * @param token authorization token
   * @return github api client
   */
  public static GitHubClient create(
      final HttpClient httpClient, final URI baseUrl, final String token) {
    return new GitHubClient(httpClient, baseUrl, null, token, null, null, null);
  }

  public static GitHubClient create(
      final HttpClient httpClient, final URI baseUrl, final URI graphqlUrl, final String token) {
    return new GitHubClient(httpClient, baseUrl, graphqlUrl, token, null, null, null);
  }

  /**
   * Receives a github client and scopes it to a certain installation ID.
   *
   * @param client the github client with a valid private key
   * @param installationId the installation ID to be scoped
   * @return github api client
   */
  public static GitHubClient scopeForInstallationId(
      final GitHubClient client, final int installationId) {
    if (client.getPrivateKey().isEmpty()) {
      throw new RuntimeException("Installation ID scoped client needs a private key");
    }
    return new GitHubClient(
        client.client,
        client.baseUrl,
        null,
        null,
        client.getPrivateKey().get(),
        client.appId,
        installationId);
  }

  public GitHubClient withScopeForInstallationId(final int installationId) {
    if (Optional.ofNullable(privateKey).isEmpty()) {
      throw new RuntimeException("Installation ID scoped client needs a private key");
    }
    return new GitHubClient(
        client, baseUrl, graphqlUrl.orElse(null), null, privateKey, appId, installationId);
  }

  /**
   * This is for clients authenticated as a GitHub App: when performing operations, the
   * "installation" of the App must be specified. This returns a {@code GitHubClient} that has been
   * scoped to the user's/organization's installation of the app, if any.
   */
  public CompletionStage> asAppScopedClient(final String owner) {
    return Async.exceptionallyCompose(
            this.createOrganisationClient(owner)
                .createGithubAppClient()
                .getInstallation()
                .thenApply(Installation::id),
            e -> {
              if (e.getCause() instanceof RequestNotOkException
                  && ((RequestNotOkException) e.getCause()).statusCode() == HTTP_NOT_FOUND) {
                return this.createUserClient(owner)
                    .createGithubAppClient()
                    .getUserInstallation()
                    .thenApply(Installation::id);
              }
              return CompletableFuture.failedFuture(e);
            })
        .thenApply(id -> Optional.of(this.withScopeForInstallationId(id)))
        .exceptionally(
            e -> {
              if (e.getCause() instanceof RequestNotOkException
                  && ((RequestNotOkException) e.getCause()).statusCode() == HTTP_NOT_FOUND) {
                return Optional.empty();
              }
              throw new RuntimeException(e);
            });
  }

  public GitHubClient withTracer(final Tracer tracer) {
    this.tracer = tracer;
    this.client.setTracer(tracer);
    return this;
  }

  public Optional getPrivateKey() {
    return Optional.ofNullable(privateKey);
  }

  public Optional getAccessToken() {
    return Optional.ofNullable(token);
  }

  /**
   * Create a repository API client
   *
   * @param owner repository owner
   * @param repo repository name
   * @return repository API client
   */
  public RepositoryClient createRepositoryClient(final String owner, final String repo) {
    return RepositoryClient.create(this, owner, repo);
  }

  /**
   * Create a GitData API client
   *
   * @param owner repository owner
   * @param repo repository name
   * @return GitData API client
   */
  public GitDataClient createGitDataClient(final String owner, final String repo) {
    return GitDataClient.create(this, owner, repo);
  }

  /**
   * Create search API client
   *
   * @return search API client
   */
  public SearchClient createSearchClient() {
    return SearchClient.create(this);
  }

  /**
   * Create a checks API client
   *
   * @param owner repository owner
   * @param repo repository name
   * @return checks API client
   */
  public ChecksClient createChecksClient(final String owner, final String repo) {
    return ChecksClient.create(this, owner, repo);
  }

  /**
   * Create organisation API client
   *
   * @return organisation API client
   */
  public OrganisationClient createOrganisationClient(final String org) {
    return OrganisationClient.create(this, org);
  }

  /**
   * Create user API client
   *
   * @return user API client
   */
  public UserClient createUserClient(final String owner) {
    return UserClient.create(this, owner);
  }

  Json json() {
    return json;
  }

  /**
   * Make a http GET request for the given path on the server
   *
   * @param path relative to the GitHub base url
   * @return response body as a String
   */
  CompletableFuture request(final String path) {
    return call("GET", path);
  }

  /**
   * Make a http GET request for the given path on the server
   *
   * @param path relative to the GitHub base url
   * @param extraHeaders extra github headers to be added to the call
   * @return a reader of response body
   */
  CompletableFuture request(
      final String path, final Map extraHeaders) {
    return call("GET", path, extraHeaders);
  }

  /**
   * Make a http GET request for the given path on the server
   *
   * @param path relative to the GitHub base url
   * @return body deserialized as provided type
   */
   CompletableFuture request(final String path, final Class clazz) {
    return call(path)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a http GET request for the given path on the server
   *
   * @param path relative to the GitHub base url
   * @param extraHeaders extra github headers to be added to the call
   * @return body deserialized as provided type
   */
   CompletableFuture request(
      final String path, final Class clazz, final Map extraHeaders) {
    return call("GET", path, null, extraHeaders)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a http request for the given path on the GitHub server.
   *
   * @param path relative to the GitHub base url
   * @param extraHeaders extra github headers to be added to the call
   * @return body deserialized as provided type
   */
   CompletableFuture request(
      final String path,
      final TypeReference typeReference,
      final Map extraHeaders) {
    return call("GET", path, null, extraHeaders)
        .thenApply(
            response -> json().fromJsonUncheckedNotNull(response.bodyString(), typeReference));
  }

  /**
   * Make a http request for the given path on the GitHub server.
   *
   * @param path relative to the GitHub base url
   * @return body deserialized as provided type
   */
   CompletableFuture request(final String path, final TypeReference typeReference) {
    return call(path)
        .thenApply(
            response -> json().fromJsonUncheckedNotNull(response.bodyString(), typeReference));
  }

  /**
   * Make a http POST request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @return response body as String
   */
  CompletableFuture post(final String path, final String data) {
    return call("POST", path, data);
  }

  /**
   * Make a http POST request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param extraHeaders
   * @return response body as String
   */
  CompletableFuture post(
      final String path, final String data, final Map extraHeaders) {
    return call("POST", path, data, extraHeaders);
  }

  /**
   * Make a http POST request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param clazz class to cast response as
   * @param extraHeaders
   * @return response body deserialized as provided class
   */
   CompletableFuture post(
      final String path,
      final String data,
      final Class clazz,
      final Map extraHeaders) {
    return post(path, data, extraHeaders)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a http POST request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param clazz class to cast response as
   * @return response body deserialized as provided class
   */
   CompletableFuture post(final String path, final String data, final Class clazz) {
    return post(path, data)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a POST request to the graphql endpoint of GitHub
   *
   * @param data request body as stringified JSON
   * @return response
   * @see
   *     "https://docs.github.com/en/[email protected]/graphql/guides/forming-calls-with-graphql#communicating-with-graphql"
   */
  public CompletableFuture postGraphql(final String data) {
    return graphqlRequestBuilder()
        .thenCompose(
            requestBuilder -> {
              final HttpRequest request = requestBuilder.method("POST").body(data).build();
              log.info("Making POST request to {}", request.url());
              return call(request);
            });
  }

  /**
   * Make a http PUT request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @return response body as String
   */
  CompletableFuture put(final String path, final String data) {
    return call("PUT", path, data);
  }

  /**
   * Make a HTTP PUT request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param clazz class to cast response as
   * @return response body deserialized as provided class
   */
   CompletableFuture put(final String path, final String data, final Class clazz) {
    return put(path, data)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a http PATCH request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @return response body as String
   */
  CompletableFuture patch(final String path, final String data) {
    return call("PATCH", path, data);
  }

  /**
   * Make a http PATCH request for the given path with provided JSON body.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param clazz class to cast response as
   * @return response body deserialized as provided class
   */
   CompletableFuture patch(final String path, final String data, final Class clazz) {
    return patch(path, data)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a http PATCH request for the given path with provided JSON body
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param clazz class to cast response as
   * @return response body deserialized as provided class
   */
   CompletableFuture patch(
      final String path,
      final String data,
      final Class clazz,
      final Map extraHeaders) {
    return call("PATCH", path, data, extraHeaders)
        .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), clazz));
  }

  /**
   * Make a http DELETE request for the given path.
   *
   * @param path relative to the GitHub base url
   * @return response body as String
   */
  CompletableFuture delete(final String path) {
    return call("DELETE", path);
  }

  /**
   * Make a http DELETE request for the given path.
   *
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @return response body as String
   */
  CompletableFuture delete(final String path, final String data) {
    return call("DELETE", path, data);
  }

  /**
   * Make a http DELETE request for the given path.
   *
   * @param path relative to the GitHub base url
   * @return response body as String
   */
  private CompletableFuture call(final String path) {
    return call("GET", path, null, null);
  }

  /**
   * Make a http request for the given path on the GitHub server.
   *
   * @param method HTTP method
   * @param path relative to the GitHub base url
   * @return response body as String
   */
  private CompletableFuture call(final String method, final String path) {
    return call(method, path, null, null);
  }

  /**
   * Make a http request for the given path on the GitHub server.
   *
   * @param method HTTP method
   * @param path relative to the GitHub base url
   * @param extraHeaders extra github headers to be added to the call
   * @return response body as String
   */
  private CompletableFuture call(
      final String method, final String path, final Map extraHeaders) {
    return call(method, path, null, extraHeaders);
  }

  /*
   * Make a http request for the given path on the GitHub server.
   *
   * @param method HTTP method
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @return response body as String
   */
  private CompletableFuture call(
      final String method, final String path, final String data) {
    return call(method, path, data, null);
  }

  /**
   * Make a http request for the given path on the GitHub server.
   *
   * @param method HTTP method
   * @param path relative to the GitHub base url
   * @param data request body as stringified JSON
   * @param extraHeaders extra github headers to be added to the call
   * @return response body as String
   */
  private CompletableFuture call(
      final String method,
      final String path,
      @Nullable final String data,
      @Nullable final Map extraHeaders) {
    return requestBuilder(path)
        .thenCompose(
            requestBuilder -> {
              final ImmutableHttpRequest.Builder builder = requestBuilder.method(method);
              if (data != null) {
                builder.body(data);
              }
              final HttpRequest request =
                  extraHeaders == null || extraHeaders.isEmpty()
                      ? builder.build()
                      : toHttpRequestHeaders(builder, extraHeaders).build();
              log.debug("Making {} request to {}", method, request.url().toString());
              return call(request);
            });
  }

  /**
   * Create a URL for a given path to this GitHub server.
   *
   * @param path relative URI
   * @return URL to path on this server
   */
  String urlFor(final String path) {
    return baseUrl.toString().replaceAll("/+$", "") + "/" + path.replaceAll("^/+", "");
  }

  /**
   * Adds extra headers to the Request Builder
   *
   * @param builder the request builder
   * @param extraHeaders the extra headers to be added
   * @return the request builder with the extra headers
   */
  private ImmutableHttpRequest.Builder toHttpRequestHeaders(
      final ImmutableHttpRequest.Builder builder, final Map extraHeaders) {
    HttpRequest request = builder.build();

    extraHeaders.forEach(
        (headerKey, headerValue) -> {
          if (request.headers().containsKey(headerKey)) {
            List headers = new ArrayList<>(request.headers().get(headerKey));
            headers.add(headerValue);
            builder.putHeaders(headerKey, headers);
          } else {
            builder.putHeaders(headerKey, List.of(headerValue));
          }
        });
    return builder;
  }

  /*
   * Create a Request Builder for this GitHub GraphQL server.
   *
   * @return GraphQL Request Builder
   */
  private CompletableFuture graphqlRequestBuilder() {
    URI url = graphqlUrl.orElseThrow(() -> new IllegalStateException("No graphql url set"));
    return requestBuilder("/graphql")
        .thenApply(requestBuilder -> requestBuilder.url(url.toString()));
  }

  /*
   * Create a Request Builder for this GitHub server.
   *
   * @param path relative URI
   * @return Request Builder
   */
  private CompletableFuture 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. (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. */ 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 completedFuture(String.format("token %s", token)); } else if (getPrivateKey().isPresent()) { final String jwtToken; try { jwtToken = JwtTokenIssuer.fromPrivateKey(privateKey).getToken(appId); } catch (Exception e) { throw new RuntimeException("There was an error generating JWT token", e); } if (isJwtRequest(path)) { return completedFuture(String.format("Bearer %s", jwtToken)); } if (installationId == null) { throw new RuntimeException("This endpoint needs a client with an installation ID"); } try { 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); } } throw new RuntimeException("Not possible to authenticate. "); } private boolean isJwtRequest(final String path) { return path.startsWith("/app/installation") || path.endsWith("installation"); } /** * 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.", installationId); return generateInstallationToken(jwtToken, installationId) .thenApply( accessToken -> { installationTokens.put(installationId, accessToken); return accessToken.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)); } /** * 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 HttpRequest request = ImmutableHttpRequest.builder() .url(url) .putHeaders("Accept", List.of("application/vnd.github.machine-man-preview+json")) .putHeaders("Authorization", List.of("Bearer " + jwtToken)) .method("POST") .body("") .build(); 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 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); 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; } /** * 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 (httpResponse.statusCode() == FORBIDDEN) { if (bodyString.contains("Repository was archived so is read-only")) { return new ReadOnlyRepositoryException( httpRequest.method(), URI.create(httpRequest.url()).getPath(), httpResponse.statusCode(), bodyString, headersMap); } } return new RequestNotOkException( httpRequest.method(), URI.create(httpRequest.url()).getPath(), httpResponse.statusCode(), bodyString, headersMap); } /** * 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.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) { 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); } } /** 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); } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy