package org.kohsuke.github; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.introspect.VisibilityChecker; import org.apache.commons.lang3.StringUtils; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InterruptedIOException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.SocketException; import java.net.SocketTimeoutException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TimeZone; import java.util.function.Consumer; import java.util.logging.Logger; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.net.ssl.SSLHandshakeException; import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; import static java.util.logging.Level.*; /** * A GitHub API Client *
* A GitHubClient can be used to send requests and retrieve their responses. GitHubClient is thread-safe and can be used * to send multiple requests. GitHubClient also track some GitHub API information such as {@link #rateLimit()}. *
*/ abstract class GitHubClient { static final int CONNECTION_ERROR_RETRIES = 2; /** * If timeout issues let's retry after milliseconds. */ static final int retryTimeoutMillis = 100; /* private */ final String login; /** * Value of the authorization header to be sent with the request. */ /* private */ final String encodedAuthorization; // Cache of myself object. private final String apiUrl; protected final RateLimitHandler rateLimitHandler; protected final AbuseLimitHandler abuseLimitHandler; private final GitHubRateLimitChecker rateLimitChecker; private HttpConnector connector; private final Object headerRateLimitLock = new Object(); private GHRateLimit headerRateLimit = null; private volatile GHRateLimit rateLimit = null; private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName()); private static final ObjectMapper MAPPER = new ObjectMapper(); static final String GITHUB_URL = "https://api.github.com"; private static final String[] TIME_FORMATS = { "yyyy/MM/dd HH:mm:ss ZZZZ", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.S'Z'" // GitHub App endpoints return a different date format }; static { MAPPER.setVisibility(new VisibilityChecker.Std(NONE, NONE, NONE, NONE, ANY)); MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); MAPPER.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true); MAPPER.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE); } GitHubClient(String apiUrl, String login, String oauthAccessToken, String jwtToken, String password, HttpConnector connector, RateLimitHandler rateLimitHandler, AbuseLimitHandler abuseLimitHandler, GitHubRateLimitChecker rateLimitChecker, Consumer* Verify that the API URL and credentials are valid to access this GitHub. * *
* This method returns normally if the endpoint is reachable and verified to be GitHub API URL. Otherwise this
* method throws {@link IOException} to indicate the problem.
*
* @throws IOException
* the io exception
*/
public void checkApiUrlValidity() throws IOException {
try {
fetch(GHApiInfo.class, "/").check(getApiUrl());
} catch (IOException e) {
if (isPrivateModeEnabled()) {
throw (IOException) new IOException(
"GitHub Enterprise server (" + getApiUrl() + ") with private mode enabled").initCause(e);
}
throw e;
}
}
public String getApiUrl() {
return apiUrl;
}
/**
* Builds a {@link GitHubRequest}, sends the {@link GitHubRequest} to the server, and uses the
* {@link GitHubResponse.BodyHandler} to parse the response info and response body data into an instance of
* {@link T}.
*
* @param builder
* used to build the request that will be sent to the server.
* @param handler
* parse the response info and body data into a instance of {@link T}. If null, no parsing occurs and
* {@link GitHubResponse#body()} will return null.
* @param
* $ curl -i https://github.mycompany.com/api/v3/
* HTTP/1.1 401 Unauthorized
* Server: GitHub.com
* Date: Sat, 05 Mar 2016 19:45:01 GMT
* Content-Type: application/json; charset=utf-8
* Content-Length: 130
* Status: 401 Unauthorized
* X-GitHub-Media-Type: github.v3
* X-XSS-Protection: 1; mode=block
* X-Frame-Options: deny
* Content-Security-Policy: default-src 'none'
* Access-Control-Allow-Credentials: true
* Access-Control-Expose-Headers: ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval
* Access-Control-Allow-Origin: *
* X-GitHub-Request-Id: dbc70361-b11d-4131-9a7f-674b8edd0411
* Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
* X-Content-Type-Options: nosniff
*
*
* @return {@code true} if private mode is enabled. If it tries to use this method with GitHub, returns {@code
* false}.
*/
private boolean isPrivateModeEnabled() {
try {
GitHubResponse> response = sendRequest(GitHubRequest.newBuilder().withApiUrl(getApiUrl()), null);
return response.statusCode() == HTTP_UNAUTHORIZED && response.headerField("X-GitHub-Media-Type") != null;
} catch (IOException e) {
return false;
}
}
/**
* Determine if one {@link GHRateLimit.Record} should replace another. Header date is only accurate to the second,
* so we look at the information in the record itself.
*
* {@link GHRateLimit.UnknownLimitRecord}s are always replaced by regular {@link GHRateLimit.Record}s. Regular
* {@link GHRateLimit.Record}s are never replaced by {@link GHRateLimit.UnknownLimitRecord}s. Candidates with
* resetEpochSeconds later than current record are more recent. Candidates with the same reset and a lower remaining
* count are more recent. Candidates with an earlier reset are older.
*
* @param candidate
* {@link GHRateLimit.Record} constructed from the response header information
* @param current
* the current {@link GHRateLimit.Record} record
*/
static boolean shouldReplace(@Nonnull GHRateLimit.Record candidate, @Nonnull GHRateLimit.Record current) {
if (candidate instanceof GHRateLimit.UnknownLimitRecord
&& !(current instanceof GHRateLimit.UnknownLimitRecord)) {
// Unknown candidate never replaces a regular record
return false;
} else if (current instanceof GHRateLimit.UnknownLimitRecord
&& !(candidate instanceof GHRateLimit.UnknownLimitRecord)) {
// Any real record should replace an unknown Record.
return true;
} else {
// records of the same type compare to each other as normal.
return current.getResetEpochSeconds() < candidate.getResetEpochSeconds()
|| (current.getResetEpochSeconds() == candidate.getResetEpochSeconds()
&& current.getRemaining() > candidate.getRemaining());
}
}
static URL parseURL(String s) {
try {
return s == null ? null : new URL(s);
} catch (MalformedURLException e) {
throw new IllegalStateException("Invalid URL: " + s);
}
}
static Date parseDate(String timestamp) {
if (timestamp == null)
return null;
for (String f : TIME_FORMATS) {
try {
SimpleDateFormat df = new SimpleDateFormat(f);
df.setTimeZone(TimeZone.getTimeZone("GMT"));
return df.parse(timestamp);
} catch (ParseException e) {
// try next
}
}
throw new IllegalStateException("Unable to parse the timestamp: " + timestamp);
}
static String printDate(Date dt) {
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
df.setTimeZone(TimeZone.getTimeZone("GMT"));
return df.format(dt);
}
/**
* Gets an {@link ObjectWriter}.
*
* @return an {@link ObjectWriter} instance that can be further configured.
*/
@Nonnull
static ObjectWriter getMappingObjectWriter() {
return MAPPER.writer();
}
/**
* Helper for {@link #getMappingObjectReader(GitHubResponse.ResponseInfo)}
*
* @param root
* the root GitHub object for this reader
*
* @return an {@link ObjectReader} instance that can be further configured.
*/
@Nonnull
static ObjectReader getMappingObjectReader(@Nonnull GitHub root) {
ObjectReader reader = getMappingObjectReader((GitHubResponse.ResponseInfo) null);
((InjectableValues.Std) reader.getInjectableValues()).addValue(GitHub.class, root);
return reader;
}
/**
* Gets an {@link ObjectReader}.
*
* Members of {@link InjectableValues} must be present even if {@code null}, otherwise classes expecting those
* values will fail to read. This differs from regular JSONProperties which provide defaults instead of failing.
*
* Having one spot to create readers and having it take all injectable values is not a great long term solution but
* it is sufficient for this first cut.
*
* @param responseInfo
* the {@link GitHubResponse.ResponseInfo} to inject for this reader.
*
* @return an {@link ObjectReader} instance that can be further configured.
*/
@Nonnull
static ObjectReader getMappingObjectReader(@CheckForNull GitHubResponse.ResponseInfo responseInfo) {
Map