GitHubClient.java
package org.kohsuke.github;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.introspect.VisibilityChecker;
import org.apache.commons.io.IOUtils;
import org.kohsuke.github.authorization.AuthorizationProvider;
import org.kohsuke.github.authorization.UserAuthorizationProvider;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.*;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
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
* <p>
* 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 GHRateLimit}.
* </p>
*/
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;
// Cache of myself object.
private final String apiUrl;
protected final RateLimitHandler rateLimitHandler;
protected final AbuseLimitHandler abuseLimitHandler;
private final GitHubRateLimitChecker rateLimitChecker;
private final AuthorizationProvider authorizationProvider;
private HttpConnector connector;
@Nonnull
private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);
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 DateTimeFormatter DATE_TIME_PARSER_SLASHES = DateTimeFormatter
.ofPattern("yyyy/MM/dd HH:mm:ss Z");
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,
HttpConnector connector,
RateLimitHandler rateLimitHandler,
AbuseLimitHandler abuseLimitHandler,
GitHubRateLimitChecker rateLimitChecker,
Consumer<GHMyself> myselfConsumer,
AuthorizationProvider authorizationProvider) throws IOException {
if (apiUrl.endsWith("/")) {
apiUrl = apiUrl.substring(0, apiUrl.length() - 1); // normalize
}
if (null == connector) {
connector = HttpConnector.DEFAULT;
}
this.apiUrl = apiUrl;
this.connector = connector;
// Prefer credential configuration via provider
this.authorizationProvider = authorizationProvider;
this.rateLimitHandler = rateLimitHandler;
this.abuseLimitHandler = abuseLimitHandler;
this.rateLimitChecker = rateLimitChecker;
this.login = getCurrentUser(myselfConsumer);
}
private String getCurrentUser(Consumer<GHMyself> myselfConsumer) throws IOException {
String login = null;
if (this.authorizationProvider instanceof UserAuthorizationProvider
&& this.authorizationProvider.getEncodedAuthorization() != null) {
UserAuthorizationProvider userAuthorizationProvider = (UserAuthorizationProvider) this.authorizationProvider;
login = userAuthorizationProvider.getLogin();
if (login == null) {
try {
GHMyself myself = fetch(GHMyself.class, "/user");
if (myselfConsumer != null) {
myselfConsumer.accept(myself);
}
login = myself.getLogin();
} catch (IOException e) {
return null;
}
}
}
return login;
}
private <T> T fetch(Class<T> type, String urlPath) throws IOException {
GitHubRequest request = GitHubRequest.newBuilder().withApiUrl(getApiUrl()).withUrlPath(urlPath).build();
return this.sendRequest(request, (responseInfo) -> GitHubResponse.parseBody(responseInfo, type)).body();
}
/**
* Ensures that the credential for this client is valid.
*
* @return the boolean
*/
public boolean isCredentialValid() {
try {
// If 404, ratelimit returns a default value.
// This works as credential test because invalid credentials returns 401, not 404
getRateLimit();
return true;
} catch (IOException e) {
LOGGER.log(FINE,
"Exception validating credentials on " + getApiUrl() + " with login '" + login + "' " + e,
e);
return false;
}
}
/**
* Is this an always offline "connection".
*
* @return {@code true} if this is an always offline "connection".
*/
public boolean isOffline() {
return getConnector() == HttpConnector.OFFLINE;
}
/**
* Gets connector.
*
* @return the connector
*/
public HttpConnector getConnector() {
return connector;
}
/**
* Sets the custom connector used to make requests to GitHub.
*
* @param connector
* the connector
* @deprecated HttpConnector should not be changed.
*/
@Deprecated
public void setConnector(HttpConnector connector) {
LOGGER.warning("Connector should not be changed. Please file an issue describing your use case.");
this.connector = connector;
}
/**
* Is this an anonymous connection
*
* @return {@code true} if operations that require authentication will fail.
*/
public boolean isAnonymous() {
try {
return login == null && this.authorizationProvider.getEncodedAuthorization() == null;
} catch (IOException e) {
// An exception here means that the provider failed to provide authorization parameters,
// basically meaning the same as "no auth"
return false;
}
}
/**
* Gets the current full rate limit information from the server.
*
* For some versions of GitHub Enterprise, the {@code /rate_limit} endpoint returns a {@code 404 Not Found}. In that
* case, the most recent {@link GHRateLimit} information will be returned, including rate limit information returned
* in the response header for this request in if was present.
*
* For most use cases it would be better to implement a {@link RateLimitChecker} and add it via
* {@link GitHubBuilder#withRateLimitChecker(RateLimitChecker)}.
*
* @return the rate limit
* @throws IOException
* the io exception
*/
@Nonnull
public GHRateLimit getRateLimit() throws IOException {
return getRateLimit(RateLimitTarget.NONE);
}
@CheckForNull
protected String getEncodedAuthorization() throws IOException {
return authorizationProvider.getEncodedAuthorization();
}
@Nonnull
GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {
GHRateLimit result;
try {
GitHubRequest request = GitHubRequest.newBuilder()
.rateLimit(RateLimitTarget.NONE)
.withApiUrl(getApiUrl())
.withUrlPath("/rate_limit")
.build();
result = this
.sendRequest(request, (responseInfo) -> GitHubResponse.parseBody(responseInfo, JsonRateLimit.class))
.body().resources;
} catch (FileNotFoundException e) {
// For some versions of GitHub Enterprise, the rate_limit endpoint returns a 404.
LOGGER.log(FINE, "/rate_limit returned 404 Not Found.");
// However some newer versions of GHE include rate limit header information
// If the header info is missing and the endpoint returns 404, fill the rate limit
// with unknown
result = GHRateLimit.fromRecord(GHRateLimit.UnknownLimitRecord.current(), rateLimitTarget);
}
return updateRateLimit(result);
}
/**
* Returns the most recently observed rate limit data.
*
* Generally, instead of calling this you should implement a {@link RateLimitChecker} or call
*
* @return the most recently observed rate limit data. This may include expired or
* {@link GHRateLimit.UnknownLimitRecord} entries.
* @deprecated implement a {@link RateLimitChecker} and add it via
* {@link GitHubBuilder#withRateLimitChecker(RateLimitChecker)}.
*/
@Nonnull
@Deprecated
GHRateLimit lastRateLimit() {
return rateLimit.get();
}
/**
* Gets the current rate limit for an endpoint while trying not to actually make any remote requests unless
* absolutely necessary.
*
* If the {@link GHRateLimit.Record} for {@code urlPath} is not expired, it is returned. If the
* {@link GHRateLimit.Record} for {@code urlPath} is expired, {@link #getRateLimit()} will be called to get the
* current rate limit.
*
* @param rateLimitTarget
* the endpoint to get the rate limit for.
*
* @return the current rate limit data. {@link GHRateLimit.Record}s in this instance may be expired when returned.
* @throws IOException
* if there was an error getting current rate limit data.
*/
@Nonnull
GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {
GHRateLimit result = rateLimit.get();
// Most of the time rate limit is not expired, so try to avoid locking.
if (result.getRecord(rateLimitTarget).isExpired()) {
// if the rate limit is expired, synchronize to ensure
// only one call to getRateLimit() is made to refresh it.
synchronized (this) {
if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {
getRateLimit(rateLimitTarget);
}
}
result = rateLimit.get();
}
return result;
}
/**
* Update the Rate Limit with the latest info from response header.
*
* Due to multi-threading, requests might complete out of order. This method calls
* {@link GHRateLimit#getMergedRateLimit(GHRateLimit)} to ensure the most current records are used.
*
* @param observed
* {@link GHRateLimit.Record} constructed from the response header information
*/
private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {
GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));
LOGGER.log(FINEST, "Rate limit now: {0}", rateLimit.get());
return result;
}
/**
* Tests the connection.
*
* <p>
* Verify that the API URL and credentials are valid to access this GitHub.
*
* <p>
* 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 <T>
* the type of the parse body data.
* @return a {@link GitHubResponse} containing the parsed body data as a {@link T}. Parsed instance may be null.
* @throws IOException
* if an I/O Exception occurs
*/
@Nonnull
public <T> GitHubResponse<T> sendRequest(@Nonnull GitHubRequest.Builder<?> builder,
@CheckForNull GitHubResponse.BodyHandler<T> handler) throws IOException {
return sendRequest(builder.build(), handler);
}
/**
* 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 request
* 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 <T>
* the type of the parse body data.
* @return a {@link GitHubResponse} containing the parsed body data as a {@link T}. Parsed instance may be null.
* @throws IOException
* if an I/O Exception occurs
*/
@Nonnull
public <T> GitHubResponse<T> sendRequest(GitHubRequest request, @CheckForNull GitHubResponse.BodyHandler<T> handler)
throws IOException {
int retries = CONNECTION_ERROR_RETRIES;
do {
// if we fail to create a connection we do not retry and we do not wrap
GitHubResponse.ResponseInfo responseInfo = null;
try {
try {
logRequest(request);
rateLimitChecker.checkRateLimit(this, request);
responseInfo = getResponseInfo(request);
noteRateLimit(responseInfo);
detectOTPRequired(responseInfo);
if (isInvalidCached404Response(responseInfo)) {
// Setting "Cache-Control" to "no-cache" stops the cache from supplying
// "If-Modified-Since" or "If-None-Match" values.
// This makes GitHub give us current data (not incorrectly cached data)
request = request.toBuilder().setHeader("Cache-Control", "no-cache").build();
continue;
}
if (!(isRateLimitResponse(responseInfo) || isAbuseLimitResponse(responseInfo))) {
return createResponse(responseInfo, handler);
}
} catch (IOException e) {
// For transient errors, retry
if (retryConnectionError(e, request.url(), retries)) {
continue;
}
throw interpretApiError(e, request, responseInfo);
}
handleLimitingErrors(responseInfo);
} finally {
IOUtils.closeQuietly(responseInfo);
}
} while (--retries >= 0);
throw new GHIOException("Ran out of retries for URL: " + request.url().toString());
}
private void logRequest(@Nonnull final GitHubRequest request) {
LOGGER.log(FINE,
() -> "GitHub API request [" + (login == null ? "anonymous" : login) + "]: " + request.method() + " "
+ request.url().toString());
}
@Nonnull
protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;
protected abstract void handleLimitingErrors(@Nonnull GitHubResponse.ResponseInfo responseInfo) throws IOException;
@Nonnull
private static <T> GitHubResponse<T> createResponse(@Nonnull GitHubResponse.ResponseInfo responseInfo,
@CheckForNull GitHubResponse.BodyHandler<T> handler) throws IOException {
T body = null;
if (responseInfo.statusCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
// special case handling for 304 unmodified, as the content will be ""
} else if (responseInfo.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {
// Response code 202 means data is being generated or an action that can require some time is triggered.
// This happens in specific cases:
// statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching
// fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork
// workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run
LOGGER.log(FINE,
"Received HTTP_ACCEPTED(202) from " + responseInfo.url().toString()
+ " . Please try again in 5 seconds.");
} else if (handler != null) {
body = handler.apply(responseInfo);
}
return new GitHubResponse<>(responseInfo, body);
}
/**
* Handle API error by either throwing it or by returning normally to retry.
*/
private static IOException interpretApiError(IOException e,
@Nonnull GitHubRequest request,
@CheckForNull GitHubResponse.ResponseInfo responseInfo) throws IOException {
// If we're already throwing a GHIOException, pass through
if (e instanceof GHIOException) {
return e;
}
int statusCode = -1;
String message = null;
Map<String, List<String>> headers = new HashMap<>();
String errorMessage = null;
if (responseInfo != null) {
statusCode = responseInfo.statusCode();
message = responseInfo.headerField("Status");
headers = responseInfo.headers();
errorMessage = responseInfo.errorMessage();
}
if (errorMessage != null) {
if (e instanceof FileNotFoundException) {
// pass through 404 Not Found to allow the caller to handle it intelligently
e = new GHFileNotFoundException(e.getMessage() + " " + errorMessage, e)
.withResponseHeaderFields(headers);
} else if (statusCode >= 0) {
e = new HttpException(errorMessage, statusCode, message, request.url().toString(), e);
} else {
e = new GHIOException(errorMessage).withResponseHeaderFields(headers);
}
} else if (!(e instanceof FileNotFoundException)) {
e = new HttpException(statusCode, message, request.url().toString(), e);
}
return e;
}
protected static boolean isRateLimitResponse(@Nonnull GitHubResponse.ResponseInfo responseInfo) {
return responseInfo.statusCode() == HttpURLConnection.HTTP_FORBIDDEN
&& "0".equals(responseInfo.headerField("X-RateLimit-Remaining"));
}
protected static boolean isAbuseLimitResponse(@Nonnull GitHubResponse.ResponseInfo responseInfo) {
return responseInfo.statusCode() == HttpURLConnection.HTTP_FORBIDDEN
&& responseInfo.headerField("Retry-After") != null;
}
private static boolean retryConnectionError(IOException e, URL url, int retries) throws IOException {
// There are a range of connection errors where we want to wait a moment and just automatically retry
boolean connectionError = e instanceof SocketException || e instanceof SocketTimeoutException
|| e instanceof SSLHandshakeException;
if (connectionError && retries > 0) {
LOGGER.log(INFO,
e.getMessage() + " while connecting to " + url + ". Sleeping " + GitHubClient.retryTimeoutMillis
+ " milliseconds before retrying... ; will try " + retries + " more time(s)");
try {
Thread.sleep(GitHubClient.retryTimeoutMillis);
} catch (InterruptedException ie) {
throw (IOException) new InterruptedIOException().initCause(e);
}
return true;
}
return false;
}
private static boolean isInvalidCached404Response(GitHubResponse.ResponseInfo responseInfo) {
// WORKAROUND FOR ISSUE #669:
// When the Requester detects a 404 response with an ETag (only happpens when the server's 304
// is bogus and would cause cache corruption), try the query again with new request header
// that forces the server to not return 304 and return new data instead.
//
// This solution is transparent to users of this library and automatically handles a
// situation that was cause insidious and hard to debug bad responses in caching
// scenarios. If GitHub ever fixes their issue and/or begins providing accurate ETags to
// their 404 responses, this will result in at worst two requests being made for each 404
// responses. However, only the second request will count against rate limit.
if (responseInfo.statusCode() == 404 && Objects.equals(responseInfo.request().method(), "GET")
&& responseInfo.headerField("ETag") != null
&& !Objects.equals(responseInfo.request().headers().get("Cache-Control"), "no-cache")) {
LOGGER.log(FINE,
"Encountered GitHub invalid cached 404 from " + responseInfo.url()
+ ". Retrying with \"Cache-Control\"=\"no-cache\"...");
return true;
}
return false;
}
private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo responseInfo) {
try {
String limitString = Objects.requireNonNull(responseInfo.headerField("X-RateLimit-Limit"),
"Missing X-RateLimit-Limit");
String remainingString = Objects.requireNonNull(responseInfo.headerField("X-RateLimit-Remaining"),
"Missing X-RateLimit-Remaining");
String resetString = Objects.requireNonNull(responseInfo.headerField("X-RateLimit-Reset"),
"Missing X-RateLimit-Reset");
int limit, remaining;
long reset;
limit = Integer.parseInt(limitString);
remaining = Integer.parseInt(remainingString);
reset = Long.parseLong(resetString);
GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, responseInfo);
updateRateLimit(GHRateLimit.fromRecord(observed, responseInfo.request().rateLimitTarget()));
} catch (NumberFormatException | NullPointerException e) {
LOGGER.log(FINEST, "Missing or malformed X-RateLimit header: ", e);
}
}
private static void detectOTPRequired(@Nonnull GitHubResponse.ResponseInfo responseInfo) throws GHIOException {
// 401 Unauthorized == bad creds or OTP request
if (responseInfo.statusCode() == HTTP_UNAUTHORIZED) {
// In the case of a user with 2fa enabled, a header with X-GitHub-OTP
// will be returned indicating the user needs to respond with an otp
if (responseInfo.headerField("X-GitHub-OTP") != null) {
throw new GHOTPRequiredException().withResponseHeaderFields(responseInfo.headers());
}
}
}
void requireCredential() {
if (isAnonymous())
throw new IllegalStateException(
"This operation requires a credential but none is given to the GitHub constructor");
}
private static class GHApiInfo {
private String rate_limit_url;
void check(String apiUrl) throws IOException {
if (rate_limit_url == null)
throw new IOException(apiUrl + " doesn't look like GitHub API URL");
// make sure that the URL is legitimate
new URL(rate_limit_url);
}
}
/**
* Checks if a GitHub Enterprise server is configured in private mode.
*
* In private mode response looks like:
*
* <pre>
* $ 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
* </pre>
*
* @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;
}
}
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;
return Date.from(parseInstant(timestamp));
}
static Instant parseInstant(String timestamp) {
if (timestamp == null)
return null;
if (timestamp.charAt(4) == '/') {
// Unsure where this is used, but retained for compatibility.
return Instant.from(DATE_TIME_PARSER_SLASHES.parse(timestamp));
} else {
return Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(timestamp));
}
}
static String printDate(Date dt) {
return DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(dt.getTime()).truncatedTo(ChronoUnit.SECONDS));
}
/**
* 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<String, Object> injected = new HashMap<>();
// Required or many things break
injected.put(GitHubResponse.ResponseInfo.class.getName(), null);
injected.put(GitHub.class.getName(), null);
if (responseInfo != null) {
injected.put(GitHubResponse.ResponseInfo.class.getName(), responseInfo);
injected.putAll(responseInfo.request().injectedMappingValues());
}
return MAPPER.reader(new InjectableValues.Std(injected));
}
}