From 92c9da5d8d15a5d8276cf36202abb121edeea9f2 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Tue, 17 Sep 2024 15:02:18 +0200 Subject: [PATCH] feat: Add OpenTelemetry Support feat: Add OpenTelemetry Support feat: Add Support for Tracing Propagation Headers --- pom.xml | 50 +- .../spotify/github/tracing/BaseTracer.java | 60 + .../spotify/github/{ => tracing}/Span.java | 6 +- .../spotify/github/tracing/TraceHelper.java | 33 + .../spotify/github/{ => tracing}/Tracer.java | 19 +- .../opencensus/OpenCensusSpan.java | 25 +- .../opencensus/OpenCensusTracer.java | 32 +- .../opentelemetry/OpenTelemetrySpan.java | 74 + .../opentelemetry/OpenTelemetryTracer.java | 72 + .../github/v3/clients/GitHubClient.java | 1806 +++++++++-------- .../spotify/github/v3/clients/NoopTracer.java | 48 +- .../OcTestExportHandler.java} | 6 +- .../OpenCensusSpanTest.java | 6 +- .../OpenCensusTracerTest.java | 14 +- .../github/tracing/OpenTelemetrySpanTest.java | 69 + .../tracing/OpenTelemetryTracerTest.java | 136 ++ .../github/tracing/OtTestExportHandler.java | 93 + .../github/v3/clients/GitHubClientTest.java | 452 +++-- 18 files changed, 1806 insertions(+), 1195 deletions(-) create mode 100644 src/main/java/com/spotify/github/tracing/BaseTracer.java rename src/main/java/com/spotify/github/{ => tracing}/Span.java (88%) create mode 100644 src/main/java/com/spotify/github/tracing/TraceHelper.java rename src/main/java/com/spotify/github/{ => tracing}/Tracer.java (71%) rename src/main/java/com/spotify/github/{ => tracing}/opencensus/OpenCensusSpan.java (71%) rename src/main/java/com/spotify/github/{ => tracing}/opencensus/OpenCensusTracer.java (71%) create mode 100644 src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetrySpan.java create mode 100644 src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java rename src/test/java/com/spotify/github/{opencensus/TestExportHandler.java => tracing/OcTestExportHandler.java} (93%) rename src/test/java/com/spotify/github/{opencensus => tracing}/OpenCensusSpanTest.java (91%) rename src/test/java/com/spotify/github/{opencensus => tracing}/OpenCensusTracerTest.java (92%) create mode 100644 src/test/java/com/spotify/github/tracing/OpenTelemetrySpanTest.java create mode 100644 src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java create mode 100644 src/test/java/com/spotify/github/tracing/OtTestExportHandler.java diff --git a/pom.xml b/pom.xml index 67f1f81b..1677a2d4 100644 --- a/pom.xml +++ b/pom.xml @@ -42,23 +42,6 @@ - - - henriquetruta - Henrique Truta - henriquet@spotify.com - Spotify AB - http://www.spotify.com - - - hewhomustnotbenamed - Abhimanyu Shegokar - abhimanyus@spotify.com - Spotify AB - http://www.spotify.com - - - apache.snapshots @@ -102,6 +85,7 @@ 3.3 0.31.1 4.11.0 + 1.42.1 ${project.groupId}.githubclient.shade @@ -120,6 +104,13 @@ import pom + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + @@ -179,6 +170,25 @@ opencensus-api ${opencensus.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-sdk-testing + + + commons-io + commons-io + 2.7 + compile + io.jsonwebtoken jjwt-impl @@ -246,12 +256,6 @@ ${okhttp.version} test - - commons-io - commons-io - 2.7 - compile - diff --git a/src/main/java/com/spotify/github/tracing/BaseTracer.java b/src/main/java/com/spotify/github/tracing/BaseTracer.java new file mode 100644 index 00000000..e979f0e2 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/BaseTracer.java @@ -0,0 +1,60 @@ +/*- + * -\-\- + * github-client + * -- + * Copyright (C) 2016 - 2021 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.tracing; + +import okhttp3.Request; + +import java.util.concurrent.CompletionStage; + +public abstract class BaseTracer implements Tracer { + @Override + public Span span(final String name, final String method, final CompletionStage future) { + return internalSpan(name, method, future); + } + + @Override + public Span span(final String path, final String method) { + return internalSpan(path, method, null); + } + + @Override + public Span span(final Request request) { + return internalSpan(request.url().toString(), request.method(), null); + } + + protected abstract Span internalSpan( + String path, + String method, + CompletionStage future); + + @Override + public void attachSpanToFuture(final Span span, final CompletionStage future) { + future.whenComplete( + (result, t) -> { + if (t == null) { + span.success(); + } else { + span.failure(t); + } + span.close(); + }); + } +} diff --git a/src/main/java/com/spotify/github/Span.java b/src/main/java/com/spotify/github/tracing/Span.java similarity index 88% rename from src/main/java/com/spotify/github/Span.java rename to src/main/java/com/spotify/github/tracing/Span.java index 70b2e939..6171aa54 100644 --- a/src/main/java/com/spotify/github/Span.java +++ b/src/main/java/com/spotify/github/tracing/Span.java @@ -18,7 +18,9 @@ * -/-/- */ -package com.spotify.github; +package com.spotify.github.tracing; + +import okhttp3.Request; public interface Span extends AutoCloseable { @@ -29,5 +31,7 @@ public interface Span extends AutoCloseable { /** Close span. Must be called for any opened span. */ @Override void close(); + + Request decorateRequest(Request request); } diff --git a/src/main/java/com/spotify/github/tracing/TraceHelper.java b/src/main/java/com/spotify/github/tracing/TraceHelper.java new file mode 100644 index 00000000..978626e8 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/TraceHelper.java @@ -0,0 +1,33 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 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.tracing; + +public class TraceHelper { + // Tracing Headers + public static final String HEADER_CLOUD_TRACE_CONTEXT = "X-Cloud-Trace-Context"; + public static final String HEADER_TRACE_PARENT = "traceparent"; + public static final String HEADER_TRACE_STATE = "tracestate"; + + // Private constructor to prevent instantiation + private TraceHelper() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } +} diff --git a/src/main/java/com/spotify/github/Tracer.java b/src/main/java/com/spotify/github/tracing/Tracer.java similarity index 71% rename from src/main/java/com/spotify/github/Tracer.java rename to src/main/java/com/spotify/github/tracing/Tracer.java index 5d5bcd9c..c44ca477 100644 --- a/src/main/java/com/spotify/github/Tracer.java +++ b/src/main/java/com/spotify/github/tracing/Tracer.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. @@ -18,15 +18,26 @@ * -/-/- */ -package com.spotify.github; +package com.spotify.github.tracing; + +import okhttp3.Request; import java.util.concurrent.CompletionStage; public interface Tracer { - /** Create scoped span. Span will be closed when future completes. */ + /** + * Create scoped span. Span will be closed when future completes. + */ Span span( String path, String method, CompletionStage future); + Span span( + String path, String method); + + Span span( + Request request); + + void attachSpanToFuture(Span span, CompletionStage future); } diff --git a/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusSpan.java similarity index 71% rename from src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java rename to src/main/java/com/spotify/github/tracing/opencensus/OpenCensusSpan.java index 1bbf15ec..22736ed0 100644 --- a/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java +++ b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusSpan.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. @@ -18,20 +18,24 @@ * -/-/- */ -package com.spotify.github.opencensus; +package com.spotify.github.tracing.opencensus; + import static java.util.Objects.requireNonNull; -import com.spotify.github.Span; + +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.TraceHelper; import com.spotify.github.v3.exceptions.RequestNotOkException; import io.opencensus.trace.AttributeValue; import io.opencensus.trace.Status; +import okhttp3.Request; -class OpenCensusSpan implements Span { +public class OpenCensusSpan implements Span { public static final int NOT_FOUND = 404; public static final int INTERNAL_SERVER_ERROR = 500; private final io.opencensus.trace.Span span; - OpenCensusSpan(final io.opencensus.trace.Span span) { + public OpenCensusSpan(final io.opencensus.trace.Span span) { this.span = requireNonNull(span); } @@ -59,5 +63,14 @@ public Span failure(final Throwable t) { public void close() { span.end(); } + + @Override + public Request decorateRequest(final Request request) { + return request.newBuilder() + .header(TraceHelper.HEADER_CLOUD_TRACE_CONTEXT, span.getContext().getTraceId().toString()) + .header(TraceHelper.HEADER_TRACE_PARENT, span.getContext().getTraceId().toString()) + .header(TraceHelper.HEADER_TRACE_STATE, span.getContext().getTracestate().toString()) + .build(); + } } diff --git a/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java similarity index 71% rename from src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java rename to src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java index d4834b34..4aedcebd 100644 --- a/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java +++ b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.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. @@ -18,10 +18,10 @@ * -/-/- */ -package com.spotify.github.opencensus; +package com.spotify.github.tracing.opencensus; -import com.spotify.github.Span; -import com.spotify.github.Tracer; +import com.spotify.github.tracing.BaseTracer; +import com.spotify.github.tracing.Span; import io.opencensus.trace.Tracing; import java.util.concurrent.CompletionStage; @@ -30,22 +30,16 @@ import static io.opencensus.trace.Span.Kind.CLIENT; import static java.util.Objects.requireNonNull; -public class OpenCensusTracer implements Tracer { +public class OpenCensusTracer extends BaseTracer { private static final io.opencensus.trace.Tracer TRACER = Tracing.getTracer(); - @Override - public Span span(final String name, final String method, final CompletionStage future) { - return internalSpan(name, method, future); - } - @SuppressWarnings("MustBeClosedChecker") - private Span internalSpan( + protected Span internalSpan( final String path, final String method, final CompletionStage future) { requireNonNull(path); - requireNonNull(future); final io.opencensus.trace.Span ocSpan = TRACER.spanBuilder("GitHub Request").setSpanKind(CLIENT).startSpan(); @@ -56,15 +50,9 @@ private Span internalSpan( ocSpan.putAttribute("method", stringAttributeValue(method)); final Span span = new OpenCensusSpan(ocSpan); - future.whenComplete( - (result, t) -> { - if (t == null) { - span.success(); - } else { - span.failure(t); - } - span.close(); - }); + if (future != null) { + attachSpanToFuture(span, future); + } return span; } diff --git a/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetrySpan.java b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetrySpan.java new file mode 100644 index 00000000..107436aa --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetrySpan.java @@ -0,0 +1,74 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 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.tracing.opentelemetry; + +import com.spotify.github.tracing.TraceHelper; +import com.spotify.github.v3.exceptions.RequestNotOkException; +import com.spotify.github.tracing.Span; +import io.opentelemetry.api.trace.StatusCode; +import okhttp3.Request; + +import static java.util.Objects.requireNonNull; + +public class OpenTelemetrySpan implements Span { + public static final int NOT_FOUND = 404; + public static final int INTERNAL_SERVER_ERROR = 500; + + private final io.opentelemetry.api.trace.Span span; + + public OpenTelemetrySpan(final io.opentelemetry.api.trace.Span span) { + this.span = requireNonNull(span); + } + + @Override + public Span success() { + span.setStatus(StatusCode.OK); + return this; + } + + @Override + public Span failure(final Throwable t) { + if (t instanceof RequestNotOkException) { + RequestNotOkException ex = (RequestNotOkException) t; + span.setAttribute("http.status_code", ex.statusCode()); + span.setAttribute("message", ex.getRawMessage()); + if (ex.statusCode() - INTERNAL_SERVER_ERROR >= 0) { + span.setAttribute("error", true); + } + } + span.setStatus(StatusCode.UNSET); + return this; + } + + @Override + public void close() { + span.end(); + } + + @Override + public Request decorateRequest(final Request request) { + return request.newBuilder() + .header(TraceHelper.HEADER_CLOUD_TRACE_CONTEXT, span.getSpanContext().getTraceId()) + .header(TraceHelper.HEADER_TRACE_PARENT, span.getSpanContext().getTraceId()) + .header(TraceHelper.HEADER_TRACE_STATE, span.getSpanContext().getTraceState().toString()) + .build(); + } +} diff --git a/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java new file mode 100644 index 00000000..27bf97fd --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java @@ -0,0 +1,72 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 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.tracing.opentelemetry; + +import com.spotify.github.tracing.BaseTracer; +import com.spotify.github.tracing.Span; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; + +import java.util.concurrent.CompletionStage; + +import static java.util.Objects.requireNonNull; + +public class OpenTelemetryTracer extends BaseTracer { + private final io.opentelemetry.api.trace.Tracer tracer; + private final OpenTelemetry openTelemetry; + + public OpenTelemetryTracer(final OpenTelemetry openTelemetry) { + this.openTelemetry = openTelemetry; + this.tracer = openTelemetry.getTracer("github-java-client"); + } + + public OpenTelemetryTracer() { + this(GlobalOpenTelemetry.get()); + } + + @SuppressWarnings("MustBeClosedChecker") + protected Span internalSpan( + final String path, + final String method, + final CompletionStage future) { + requireNonNull(path); + + final io.opentelemetry.api.trace.Span otSpan = + tracer.spanBuilder("GitHub Request") + .setParent(Context.current()) + .setSpanKind(SpanKind.CLIENT).startSpan(); + + otSpan.setAttribute("component", "github-api-client"); + otSpan.setAttribute("peer.service", "github"); + otSpan.setAttribute("http.url", path); + otSpan.setAttribute("method", method); + final Span span = new OpenTelemetrySpan(otSpan); + + if (future == null) { + return span; + } else { + attachSpanToFuture(span, future); + } + return span; + } +} diff --git a/src/main/java/com/spotify/github/v3/clients/GitHubClient.java b/src/main/java/com/spotify/github/v3/clients/GitHubClient.java index 803bfe51..195ef801 100644 --- a/src/main/java/com/spotify/github/v3/clients/GitHubClient.java +++ b/src/main/java/com/spotify/github/v3/clients/GitHubClient.java @@ -20,11 +20,9 @@ package com.spotify.github.v3.clients; -import static java.util.concurrent.CompletableFuture.completedFuture; -import static okhttp3.MediaType.parse; - import com.fasterxml.jackson.core.type.TypeReference; -import com.spotify.github.Tracer; +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.Tracer; import com.spotify.github.async.Async; import com.spotify.github.jackson.Json; import com.spotify.github.v3.Team; @@ -39,14 +37,17 @@ 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.Branch; -import com.spotify.github.v3.repos.CommitItem; -import com.spotify.github.v3.repos.FolderContent; -import com.spotify.github.v3.repos.Repository; -import com.spotify.github.v3.repos.Status; -import com.spotify.github.v3.repos.RepositoryInvitation; - -import java.io.*; +import com.spotify.github.v3.repos.*; +import okhttp3.*; +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; import java.lang.invoke.MethodHandles; import java.net.URI; import java.time.ZonedDateTime; @@ -59,317 +60,326 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -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; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static okhttp3.MediaType.parse; /** - * Github client is a main communication entry point. Provides lower level communication + * 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.body() != null) { - response.body().close(); - } - }; - static final TypeReference> LIST_COMMENT_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_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<>() {}; - - 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 OkHttpClient client; - private final String token; - - private final byte[] privateKey; - private final Integer appId; - private final Integer installationId; - - private final Map installationTokens; - - 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 = 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); - } - - /** - * 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); - } - - static String responseBodyUnchecked(final Response response) { - try (ResponseBody body = response.body()) { - return body.string(); - } catch (IOException e) { - throw new UncheckedIOException("Failed getting response body for: " + response, e); - } - } - - 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); - } + 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.body() != null) { + response.body().close(); + } + }; + static final TypeReference> LIST_COMMENT_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_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<>() { + }; + + 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 OkHttpClient client; + private final String token; + + private final byte[] privateKey; + private final Integer appId; + private final Integer installationId; + + private final Map installationTokens; + + 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 = 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); + } + + /** + * 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); + } + + static String responseBodyUnchecked(final Response response) { + try (ResponseBody body = response.body()) { + return body.string(); + } catch (IOException e) { + throw new UncheckedIOException("Failed getting response body for: " + response, e); + } + } + + 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, @@ -402,587 +412,589 @@ public CompletionStage> asAppScopedClient(final String ow }); } - public GitHubClient withTracer(final Tracer tracer) { - this.tracer = 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 an 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) { - final Request request = requestBuilder(path).build(); - log.debug("Making request to {}", request.url().toString()); - return call(request); - } - - /** - * Make an 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) { - final Request.Builder builder = requestBuilder(path); - extraHeaders.forEach(builder::addHeader); - final Request request = builder.build(); - log.debug("Making request to {}", request.url().toString()); - return call(request); - } - - /** - * Make an 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) { - final Request request = requestBuilder(path).build(); - log.debug("Making request to {}", request.url().toString()); - return call(request) - .thenApply(body -> json().fromJsonUncheckedNotNull(responseBodyUnchecked(body), clazz)); - } - - /** - * Make an 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) { - final Request.Builder builder = requestBuilder(path); - extraHeaders.forEach(builder::addHeader); - final Request request = builder.build(); - log.debug("Making request to {}", request.url().toString()); - return call(request) - .thenApply(body -> json().fromJsonUncheckedNotNull(responseBodyUnchecked(body), clazz)); - } - - /** - * Make an 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) { - final Request.Builder builder = requestBuilder(path); - extraHeaders.forEach(builder::addHeader); - final Request request = builder.build(); - log.debug("Making request to {}", request.url().toString()); - return call(request) - .thenApply( - response -> - json().fromJsonUncheckedNotNull(responseBodyUnchecked(response), typeReference)); - } - - /** - * Make an 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) { - final Request request = requestBuilder(path).build(); - log.debug("Making request to {}", request.url().toString()); - return call(request) - .thenApply( - response -> - json().fromJsonUncheckedNotNull(responseBodyUnchecked(response), typeReference)); - } - - /** - * Make an 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) { - final Request request = - requestBuilder(path) - .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) - .build(); - log.debug("Making POST request to {}", request.url().toString()); - return call(request); - } - - /** - * Make an 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) { - final Request.Builder builder = - requestBuilder(path) - .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)); - extraHeaders.forEach(builder::addHeader); - final Request request = builder.build(); - log.debug("Making POST request to {}", request.url().toString()); - return call(request); - } - - /** - * Make an 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(responseBodyUnchecked(response), clazz)); - } - - /** - * Make an 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(responseBodyUnchecked(response), 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/enterprise-server@3.9/graphql/guides/forming-calls-with-graphql#communicating-with-graphql" - */ - public CompletableFuture postGraphql(final String data) { - final Request request = - graphqlRequestBuilder() - .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) - .build(); - log.info("Making POST request to {}", request.url()); - return call(request); - } - - /** - * Make an 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) { - final Request request = - requestBuilder(path) - .method("PUT", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) - .build(); - log.debug("Making POST request to {}", request.url().toString()); - 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 - * @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(responseBodyUnchecked(response), clazz)); - } - - /** - * Make an 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) { - final Request request = - requestBuilder(path) - .method("PATCH", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) - .build(); - log.debug("Making PATCH request to {}", request.url().toString()); - return call(request); - } - - /** - * Make an 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(responseBodyUnchecked(response), clazz)); - } - - /** - * Make an 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) { - final Request.Builder builder = - requestBuilder(path) - .method("PATCH", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)); - extraHeaders.forEach(builder::addHeader); - final Request request = builder.build(); - log.debug("Making PATCH request to {}", request.url().toString()); - return call(request) - .thenApply( - response -> json().fromJsonUncheckedNotNull(responseBodyUnchecked(response), clazz)); - } - - /** - * Make an 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) { - final Request request = requestBuilder(path).delete().build(); - log.debug("Making DELETE request to {}", request.url().toString()); - return call(request); - } - - /** - * Make an 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) { - final Request request = - requestBuilder(path) - .method("DELETE", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) - .build(); - log.debug("Making DELETE request to {}", 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("^/+", ""); - } - - private Request.Builder requestBuilder(final String path) { - final Request.Builder builder = - new Request.Builder() - .url(urlFor(path)) - .addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) - .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); - builder.addHeader(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(path)); - - return builder; - } - - private Request.Builder graphqlRequestBuilder() { - URI url = graphqlUrl.orElseThrow(() -> new IllegalStateException("No graphql url set")); - final Request.Builder builder = - new Request.Builder() - .url(url.toString()) - .addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) - .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); - builder.addHeader(HttpHeaders.AUTHORIZATION, getAuthorizationHeader("/graphql")); - return builder; - } - - 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 String 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); - } 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 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)); - } 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"); - } - - private String getInstallationToken(final String jwtToken, final int installationId) - throws Exception { - - 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); - installationToken = generateInstallationToken(jwtToken, installationId); - installationTokens.put(installationId, installationToken); - } - return installationToken.token(); - } - - 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); - 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) - .url(url) - .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), "")) - .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); - } - - private CompletableFuture call(final Request request) { - final Call call = client.newCall(request); - - 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(); - } + public GitHubClient withTracer(final Tracer tracer) { + this.tracer = 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 an 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) { + final Request request = requestBuilder(path).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request); + } + + /** + * Make an 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) { + final Request.Builder builder = requestBuilder(path); + extraHeaders.forEach(builder::addHeader); + final Request request = builder.build(); + log.debug("Making request to {}", request.url().toString()); + return call(request); + } + + /** + * Make an 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) { + final Request request = requestBuilder(path).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply(body -> json().fromJsonUncheckedNotNull(responseBodyUnchecked(body), clazz)); + } + + /** + * Make an 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) { + final Request.Builder builder = requestBuilder(path); + extraHeaders.forEach(builder::addHeader); + final Request request = builder.build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply(body -> json().fromJsonUncheckedNotNull(responseBodyUnchecked(body), clazz)); + } + + /** + * Make an 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) { + final Request.Builder builder = requestBuilder(path); + extraHeaders.forEach(builder::addHeader); + final Request request = builder.build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply( + response -> + json().fromJsonUncheckedNotNull(responseBodyUnchecked(response), typeReference)); + } + + /** + * Make an 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) { + final Request request = requestBuilder(path).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply( + response -> + json().fromJsonUncheckedNotNull(responseBodyUnchecked(response), typeReference)); + } + + /** + * Make an 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) { + final Request request = + requestBuilder(path) + .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) + .build(); + log.debug("Making POST request to {}", request.url().toString()); + return call(request); + } + + /** + * Make an 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) { + final Request.Builder builder = + requestBuilder(path) + .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)); + extraHeaders.forEach(builder::addHeader); + final Request request = builder.build(); + log.debug("Making POST request to {}", request.url().toString()); + return call(request); + } + + /** + * Make an 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(responseBodyUnchecked(response), clazz)); + } + + /** + * Make an 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(responseBodyUnchecked(response), 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/enterprise-server@3.9/graphql/guides/forming-calls-with-graphql#communicating-with-graphql" + */ + public CompletableFuture postGraphql(final String data) { + final Request request = + graphqlRequestBuilder() + .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) + .build(); + log.info("Making POST request to {}", request.url()); + return call(request); + } + + /** + * Make an 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) { + final Request request = + requestBuilder(path) + .method("PUT", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) + .build(); + log.debug("Making POST request to {}", request.url().toString()); + 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 + * @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(responseBodyUnchecked(response), clazz)); + } + + /** + * Make an 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) { + final Request request = + requestBuilder(path) + .method("PATCH", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) + .build(); + log.debug("Making PATCH request to {}", request.url().toString()); + return call(request); + } + + /** + * Make an 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(responseBodyUnchecked(response), clazz)); + } + + /** + * Make an 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) { + final Request.Builder builder = + requestBuilder(path) + .method("PATCH", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)); + extraHeaders.forEach(builder::addHeader); + final Request request = builder.build(); + log.debug("Making PATCH request to {}", request.url().toString()); + return call(request) + .thenApply( + response -> json().fromJsonUncheckedNotNull(responseBodyUnchecked(response), clazz)); + } + + /** + * Make an 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) { + final Request request = requestBuilder(path).delete().build(); + log.debug("Making DELETE request to {}", request.url().toString()); + return call(request); + } + + /** + * Make an 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) { + final Request request = + requestBuilder(path) + .method("DELETE", RequestBody.create(parse(MediaType.APPLICATION_JSON), data)) + .build(); + log.debug("Making DELETE request to {}", 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("^/+", ""); + } + + private Request.Builder requestBuilder(final String path) { + final Request.Builder builder = + new Request.Builder() + .url(urlFor(path)) + .addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + builder.addHeader(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(path)); + + return builder; + } + + private Request.Builder graphqlRequestBuilder() { + URI url = graphqlUrl.orElseThrow(() -> new IllegalStateException("No graphql url set")); + final Request.Builder builder = + new Request.Builder() + .url(url.toString()) + .addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + builder.addHeader(HttpHeaders.AUTHORIZATION, getAuthorizationHeader("/graphql")); + return builder; + } + + 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 String 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); + } 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 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)); + } 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"); + } + + private String getInstallationToken(final String jwtToken, final int installationId) + throws Exception { + + 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); + installationToken = generateInstallationToken(jwtToken, installationId); + installationTokens.put(installationId, installationToken); + } + return installationToken.token(); + } + + 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); + 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) + .url(url) + .method("POST", RequestBody.create(parse(MediaType.APPLICATION_JSON), "")) + .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); + } + + private CompletableFuture call(final Request request) { + try (Span span = tracer.span(request)) { + Request tracedRequest = span.decorateRequest(request); + final Call call = client.newCall(tracedRequest); + + 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; + }); } - } else { - future.complete(res); - } - return res; }); - } - }); - tracer.span(request.url().toString(), request.method(), future); - 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(); - - if (res.code() == 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 RequestNotOkException(request.method(), request.url().encodedPath(), res.code(), bodyString, headersMap); - } - - CompletableFuture processPossibleRedirects( - final Response response, final AtomicBoolean redirected) { - if (response.code() >= PERMANENT_REDIRECT - && response.code() <= 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); - } - - 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); - } - } + tracer.attachSpanToFuture(span, future); + 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(); + + if (res.code() == 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 RequestNotOkException(request.method(), request.url().encodedPath(), res.code(), bodyString, headersMap); + } + + CompletableFuture processPossibleRedirects( + final Response response, final AtomicBoolean redirected) { + if (response.code() >= PERMANENT_REDIRECT + && response.code() <= 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); + } + + 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); + } + } } diff --git a/src/main/java/com/spotify/github/v3/clients/NoopTracer.java b/src/main/java/com/spotify/github/v3/clients/NoopTracer.java index 5b3c769e..d46baab9 100644 --- a/src/main/java/com/spotify/github/v3/clients/NoopTracer.java +++ b/src/main/java/com/spotify/github/v3/clients/NoopTracer.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. @@ -19,11 +19,15 @@ */ package com.spotify.github.v3.clients; -import com.spotify.github.Span; -import com.spotify.github.Tracer; + +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.Tracer; +import okhttp3.Request; import java.util.concurrent.CompletionStage; +import static java.util.Objects.requireNonNull; + public class NoopTracer implements Tracer { public static final NoopTracer INSTANCE = new NoopTracer(); @@ -40,10 +44,17 @@ public Span failure(final Throwable t) { } @Override - public void close() {} + public void close() { + } + + @Override + public Request decorateRequest(final Request request) { + return request; + } }; - private NoopTracer() {} + private NoopTracer() { + } @Override public Span span( @@ -53,5 +64,30 @@ public Span span( return SPAN; } + @Override + public Span span(final String path, final String method) { + return SPAN; + } + + @Override + public Span span(final Request request) { + return SPAN; + } + + @Override + public void attachSpanToFuture(final Span span, final CompletionStage future) { + requireNonNull(span); + requireNonNull(future); + future.whenComplete( + (result, t) -> { + if (t == null) { + span.success(); + } else { + span.failure(t); + } + span.close(); + }); + } + } diff --git a/src/test/java/com/spotify/github/opencensus/TestExportHandler.java b/src/test/java/com/spotify/github/tracing/OcTestExportHandler.java similarity index 93% rename from src/test/java/com/spotify/github/opencensus/TestExportHandler.java rename to src/test/java/com/spotify/github/tracing/OcTestExportHandler.java index c1519572..b5919c99 100644 --- a/src/test/java/com/spotify/github/opencensus/TestExportHandler.java +++ b/src/test/java/com/spotify/github/tracing/OcTestExportHandler.java @@ -18,7 +18,7 @@ * -/-/- */ -package com.spotify.github.opencensus; +package com.spotify.github.tracing; import io.opencensus.trace.export.SpanData; import io.opencensus.trace.export.SpanExporter; @@ -39,8 +39,8 @@ * forever until the given number of spans is exported, which could be never. So instead we define * our own very simple implementation. */ -class TestExportHandler extends SpanExporter.Handler { - private static final Logger LOG = LoggerFactory.getLogger(TestExportHandler.class); +class OcTestExportHandler extends SpanExporter.Handler { + private static final Logger LOG = LoggerFactory.getLogger(OcTestExportHandler.class); private final List receivedSpans = new ArrayList<>(); private final Object lock = new Object(); diff --git a/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java b/src/test/java/com/spotify/github/tracing/OpenCensusSpanTest.java similarity index 91% rename from src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java rename to src/test/java/com/spotify/github/tracing/OpenCensusSpanTest.java index c6b5d94d..7bf4bbdf 100644 --- a/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java +++ b/src/test/java/com/spotify/github/tracing/OpenCensusSpanTest.java @@ -18,9 +18,9 @@ * -/-/- */ -package com.spotify.github.opencensus; +package com.spotify.github.tracing; -import com.spotify.github.Span; +import com.spotify.github.tracing.opencensus.OpenCensusSpan; import com.spotify.github.v3.exceptions.RequestNotOkException; import io.opencensus.trace.AttributeValue; import io.opencensus.trace.Status; @@ -31,7 +31,7 @@ import static org.mockito.Mockito.verify; class OpenCensusSpanTest { - private io.opencensus.trace.Span wrapped = mock(io.opencensus.trace.Span.class); + private final io.opencensus.trace.Span wrapped = mock(io.opencensus.trace.Span.class); @Test public void succeed() { diff --git a/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java b/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java similarity index 92% rename from src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java rename to src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java index 27b5c52b..161f97b0 100644 --- a/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java +++ b/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java @@ -18,11 +18,13 @@ * -/-/- */ -package com.spotify.github.opencensus; +package com.spotify.github.tracing; +import com.spotify.github.tracing.opencensus.OpenCensusTracer; import io.grpc.Context; import io.opencensus.trace.*; +import io.opencensus.trace.Span; import io.opencensus.trace.config.TraceConfig; import io.opencensus.trace.config.TraceParams; import io.opencensus.trace.export.SpanData; @@ -43,7 +45,7 @@ public class OpenCensusTracerTest { private final String rootSpanName = "root span"; - private TestExportHandler spanExporterHandler; + private OcTestExportHandler spanExporterHandler; /** * Test that trace() a) returns a future that completes when the input future completes and b) @@ -52,7 +54,7 @@ public class OpenCensusTracerTest { */ @Test public void testTrace_CompletionStage_Simple() throws Exception { - Span rootSpan = startRootSpan(); + io.opencensus.trace.Span rootSpan = startRootSpan(); final CompletableFuture future = new CompletableFuture<>(); OpenCensusTracer tracer = new OpenCensusTracer(); @@ -78,7 +80,7 @@ public void testTrace_CompletionStage_Simple() throws Exception { @Test public void testTrace_CompletionStage_Fails() throws Exception { - Span rootSpan = startRootSpan(); + io.opencensus.trace.Span rootSpan = startRootSpan(); final CompletableFuture future = new CompletableFuture<>(); OpenCensusTracer tracer = new OpenCensusTracer(); @@ -102,7 +104,7 @@ public void testTrace_CompletionStage_Fails() throws Exception { assertEquals(Status.UNKNOWN, inner.getStatus()); } - private Span startRootSpan() { + private io.opencensus.trace.Span startRootSpan() { Span rootSpan = Tracing.getTracer().spanBuilder(rootSpanName).startSpan(); Context context = ContextUtils.withValue(Context.current(), rootSpan); context.attach(); @@ -115,7 +117,7 @@ private SpanData findSpan(final List spans, final String name) { @BeforeEach public void setUpExporter() { - spanExporterHandler = new TestExportHandler(); + spanExporterHandler = new OcTestExportHandler(); Tracing.getExportComponent().getSpanExporter().registerHandler("test", spanExporterHandler); } diff --git a/src/test/java/com/spotify/github/tracing/OpenTelemetrySpanTest.java b/src/test/java/com/spotify/github/tracing/OpenTelemetrySpanTest.java new file mode 100644 index 00000000..4cdda204 --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OpenTelemetrySpanTest.java @@ -0,0 +1,69 @@ +/*- + * -\-\- + * github-api + * -- + * Copyright (C) 2021 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.tracing; + +import com.spotify.github.tracing.opentelemetry.OpenTelemetrySpan; +import com.spotify.github.v3.exceptions.RequestNotOkException; +import io.opentelemetry.api.trace.StatusCode; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class OpenTelemetrySpanTest { + private final io.opentelemetry.api.trace.Span wrapped = mock(io.opentelemetry.api.trace.Span.class); + + @Test + public void succeed() { + final Span span = new OpenTelemetrySpan(wrapped); + span.success(); + span.close(); + + verify(wrapped).setStatus(StatusCode.OK); + verify(wrapped).end(); + } + + @Test + public void fail() { + final Span span = new OpenTelemetrySpan(wrapped); + span.failure(new RequestNotOkException("method", "path", 404, "Not found", Collections.emptyMap())); + span.close(); + + verify(wrapped).setStatus(StatusCode.UNSET); + verify(wrapped).setAttribute("http.status_code", 404); + verify(wrapped).end(); + } + + @Test + public void failOnServerError() { + final Span span = new OpenTelemetrySpan(wrapped); + span.failure(new RequestNotOkException("method", "path", 500, "Internal Server Error", Collections.emptyMap())); + span.close(); + + verify(wrapped).setStatus(StatusCode.UNSET); + verify(wrapped).setAttribute("http.status_code", 500); + verify(wrapped).setAttribute("error", true); + verify(wrapped).end(); + } + +} diff --git a/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java b/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java new file mode 100644 index 00000000..d455ecd7 --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java @@ -0,0 +1,136 @@ +/*- + * -\-\- + * github-client + * -- + * Copyright (C) 2016 - 2021 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.tracing; + + +import com.spotify.github.tracing.opentelemetry.OpenTelemetryTracer; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class OpenTelemetryTracerTest { + + private final String rootSpanName = "root span"; + private static OtTestExportHandler spanExporterHandler; + private OpenTelemetry openTelemetry= GlobalOpenTelemetry.get(); + private Tracer tracer = openTelemetry.getTracer("github-java-client-test"); + + /** + * Test that trace() a) returns a future that completes when the input future completes and b) + * sets up the Spans appropriately so that the Span for the operation is exported with the + * rootSpan set as the parent. + */ + @Test + public void testTrace_CompletionStage_Simple() throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenTelemetryTracer tracer = new OpenTelemetryTracer(); + + tracer.span("path", "GET", future); + future.complete("all done"); + rootSpan.end(); + + List exportedSpans = spanExporterHandler.waitForSpansToBeExported(2); + assertEquals(2, exportedSpans.size()); + + SpanData root = findSpan(exportedSpans, rootSpanName); + SpanData inner = findSpan(exportedSpans, "GitHub Request"); + + assertEquals(root.getSpanContext().getTraceId(), inner.getSpanContext().getTraceId()); + assertEquals(root.getSpanContext().getSpanId(), inner.getParentSpanId()); + final Attributes attributes = inner.getAttributes(); + assertEquals("github-api-client", attributes.get(AttributeKey.stringKey("component"))); + assertEquals("github", attributes.get(AttributeKey.stringKey("peer.service"))); + assertEquals("path", attributes.get(AttributeKey.stringKey("http.url"))); + assertEquals("GET", attributes.get(AttributeKey.stringKey("method"))); + assertEquals(StatusCode.OK, inner.getStatus().getStatusCode()); + } + + @Test + public void testTrace_CompletionStage_Fails() throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenTelemetryTracer tracer = new OpenTelemetryTracer(); + + tracer.span("path", "POST", future); + future.completeExceptionally(new Exception("GitHub failed!")); + rootSpan.end(); + + List exportedSpans = spanExporterHandler.waitForSpansToBeExported(2); + assertEquals(2, exportedSpans.size()); + + SpanData root = findSpan(exportedSpans, rootSpanName); + SpanData inner = findSpan(exportedSpans, "GitHub Request"); + + assertEquals(root.getSpanContext().getTraceId(), inner.getSpanContext().getTraceId()); + assertEquals(root.getSpanContext().getSpanId(), inner.getParentSpanId()); + final Attributes attributes = inner.getAttributes(); + assertEquals("github-api-client", attributes.get(AttributeKey.stringKey("component"))); + assertEquals("github", attributes.get(AttributeKey.stringKey("peer.service"))); + assertEquals("path", attributes.get(AttributeKey.stringKey("http.url"))); + assertEquals("POST", attributes.get(AttributeKey.stringKey("method"))); + assertEquals(StatusCode.UNSET, inner.getStatus().getStatusCode()); + } + + private Span startRootSpan() { + Span rootSpan = tracer.spanBuilder(rootSpanName).startSpan(); + Context context = Context.current().with(rootSpan); + context.makeCurrent(); + return rootSpan; + } + + private SpanData findSpan(final List spans, final String name) { + return spans.stream().filter(s -> s.getName().equals(name)).findFirst().get(); + } + + @AfterEach + public void flushSpans() { + spanExporterHandler.flush(); + } + + @BeforeAll + public static void setupTracing() { + spanExporterHandler = new OtTestExportHandler(); + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporterHandler)) + .setSampler(Sampler.alwaysOn()) + .build(); + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal(); + } +} \ No newline at end of file diff --git a/src/test/java/com/spotify/github/tracing/OtTestExportHandler.java b/src/test/java/com/spotify/github/tracing/OtTestExportHandler.java new file mode 100644 index 00000000..5e68285f --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OtTestExportHandler.java @@ -0,0 +1,93 @@ +/*- + * -\-\- + * github-client + * -- + * Copyright (C) 2016 - 2021 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.tracing; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * A dummy SpanExporter.Handler which keeps any exported Spans in memory, so we can query against + * them in tests. + * + *

The opencensus-testing library has a TestHandler that can be used in tests like this, but the + * only method it exposes to gain access to the received spans is waitForExport(int) which blocks + * forever until the given number of spans is exported, which could be never. So instead we define + * our own very simple implementation. + */ +class OtTestExportHandler implements SpanExporter { + private static final Logger LOG = LoggerFactory.getLogger(OtTestExportHandler.class); + + private final List receivedSpans = new ArrayList<>(); + private final Object lock = new Object(); + @Override + public CompletableResultCode export(Collection spanDataList) { + synchronized (lock) { + receivedSpans.addAll(spanDataList); + LOG.info("received {} spans, {} total", spanDataList.size(), receivedSpans.size()); + } + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + this.receivedSpans.clear(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + + List receivedSpans() { + synchronized (lock) { + return new ArrayList<>(receivedSpans); + } + } + + /** Wait up to waitTime for at least `count` spans to be exported */ + List waitForSpansToBeExported(final int count) throws InterruptedException { + Duration waitTime = Duration.ofSeconds(7); + Instant deadline = Instant.now().plus(waitTime); + + List spanData = receivedSpans(); + while (spanData.size() < count) { + //noinspection BusyWait + Thread.sleep(100); + spanData = receivedSpans(); + + if (!Instant.now().isBefore(deadline)) { + LOG.warn("ending busy wait for spans because deadline passed"); + break; + } + } + return spanData; + } +} diff --git a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java index 53541217..b6df2a99 100644 --- a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.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. @@ -33,7 +33,8 @@ import static org.mockito.Mockito.*; import com.google.common.io.Resources; -import com.spotify.github.Tracer; +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.Tracer; import com.spotify.github.v3.checks.CheckSuiteResponseList; import com.spotify.github.v3.checks.Installation; import com.spotify.github.v3.exceptions.ReadOnlyRepositoryException; @@ -41,7 +42,6 @@ import com.spotify.github.v3.repos.CommitItem; import com.spotify.github.v3.repos.RepositoryInvitation; -import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -50,6 +50,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; + import okhttp3.Call; import okhttp3.Callback; import okhttp3.Headers; @@ -66,225 +67,228 @@ public class GitHubClientTest { - private GitHubClient github; - private OkHttpClient client; - private Tracer tracer = mock(Tracer.class); - - private static String getFixture(String resource) throws IOException { - return Resources.toString(getResource(GitHubClientTest.class, resource), defaultCharset()); - } - - @BeforeEach - public void setUp() { - client = mock(OkHttpClient.class); - github = GitHubClient.create(client, URI.create("http://bogus"), "token"); - } - - @Test - public void withScopedInstallationIdShouldFailWhenMissingPrivateKey() { - assertThrows(RuntimeException.class, () -> github.withScopeForInstallationId(1)); - } - - @Test - public void testWithScopedInstallationId() throws URISyntaxException { - GitHubClient org = GitHubClient.create(new URI("http://apa.bepa.cepa"), "some_key_content".getBytes(), null, null); - GitHubClient scoped = org.withScopeForInstallationId(1); - Assertions.assertTrue(scoped.getPrivateKey().isPresent()); - Assertions.assertEquals(org.getPrivateKey().get(), scoped.getPrivateKey().get()); - } - - @Test - public void testSearchIssue() throws Throwable { - - final Call call = mock(Call.class); - final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); - doNothing().when(call).enqueue(capture.capture()); - - final Response response = - new okhttp3.Response.Builder() - .code(403) - .body( - ResponseBody.create( - MediaType.get("application/json"), - "{\"message\":\"Repository " - + "was archived so is " - + "read-only.\"," - + "\"documentation_url" - + "\":\"https://developer" - + ".github.com/enterprise/2" - + ".12/v3/repos/comments" - + "/#update-a-commit-comment" - + "\"}")) - .message("foo") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://localhost/").build()) - .build(); - - when(client.newCall(any())).thenReturn(call); - IssueClient issueClient = - github.withTracer(tracer).createRepositoryClient("testorg", "testrepo").createIssueClient(); - - CompletableFuture maybeSucceeded = issueClient.editComment(1, "some comment"); - capture.getValue().onResponse(call, response); - verify(tracer,times(1)).span(anyString(), anyString(),any()); - - Exception exception = assertThrows(ExecutionException.class, - maybeSucceeded::get); - Assertions.assertEquals(ReadOnlyRepositoryException.class, exception.getCause().getClass()); - } - - @Test - public void testRequestNotOkException() throws Throwable { - final Call call = mock(Call.class); - final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); - doNothing().when(call).enqueue(capture.capture()); - - final Response response = new okhttp3.Response.Builder() - .code(409) // Conflict - .headers(Headers.of("x-ratelimit-remaining", "0")) - .body( - ResponseBody.create( - MediaType.get("application/json"), - "{\n \"message\": \"Merge Conflict\"\n}" - )) - .message("") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://localhost/").build()) - .build(); - - when(client.newCall(any())).thenReturn(call); - RepositoryClient repoApi = github.createRepositoryClient("testorg", "testrepo"); - - CompletableFuture> future = repoApi.merge("basebranch", "headbranch"); - capture.getValue().onResponse(call, response); - try { - future.get(); - Assertions.fail("Did not throw"); - } catch (ExecutionException e) { - assertThat(e.getCause() instanceof RequestNotOkException, is(true)); - RequestNotOkException e1 = (RequestNotOkException) e.getCause(); - assertThat(e1.statusCode(), is(409)); - assertThat(e1.method(), is("POST")); - assertThat(e1.path(), is("/repos/testorg/testrepo/merges")); - assertThat(e1.headers(), hasEntry("x-ratelimit-remaining", List.of("0"))); - assertThat(e1.getMessage(), containsString("POST")); - assertThat(e1.getMessage(), containsString("/repos/testorg/testrepo/merges")); - assertThat(e1.getMessage(), containsString("Merge Conflict")); - assertThat(e1.getRawMessage(), containsString("Merge Conflict")); + private GitHubClient github; + private OkHttpClient client; + private final Tracer tracer = mock(Tracer.class); + private final Span mockSpan = mock(Span.class); + + private static String getFixture(String resource) throws IOException { + return Resources.toString(getResource(GitHubClientTest.class, resource), defaultCharset()); + } + + @BeforeEach + public void setUp() { + client = mock(OkHttpClient.class); + github = GitHubClient.create(client, URI.create("http://bogus"), "token"); + when(tracer.span(any())).thenReturn(mockSpan); + when(mockSpan.decorateRequest(any())).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + public void withScopedInstallationIdShouldFailWhenMissingPrivateKey() { + assertThrows(RuntimeException.class, () -> github.withScopeForInstallationId(1)); + } + + @Test + public void testWithScopedInstallationId() throws URISyntaxException { + GitHubClient org = GitHubClient.create(new URI("http://apa.bepa.cepa"), "some_key_content".getBytes(), null, null); + GitHubClient scoped = org.withScopeForInstallationId(1); + Assertions.assertTrue(scoped.getPrivateKey().isPresent()); + Assertions.assertEquals(org.getPrivateKey().get(), scoped.getPrivateKey().get()); + } + + @Test + public void testSearchIssue() throws Throwable { + + final Call call = mock(Call.class); + final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(capture.capture()); + + final Response response = + new okhttp3.Response.Builder() + .code(403) + .body( + ResponseBody.create( + MediaType.get("application/json"), + "{\"message\":\"Repository " + + "was archived so is " + + "read-only.\"," + + "\"documentation_url" + + "\":\"https://developer" + + ".github.com/enterprise/2" + + ".12/v3/repos/comments" + + "/#update-a-commit-comment" + + "\"}")) + .message("foo") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://localhost/").build()) + .build(); + + when(client.newCall(any())).thenReturn(call); + IssueClient issueClient = + github.withTracer(tracer).createRepositoryClient("testorg", "testrepo").createIssueClient(); + + CompletableFuture maybeSucceeded = issueClient.editComment(1, "some comment"); + capture.getValue().onResponse(call, response); + verify(tracer, times(1)).span(any(Request.class)); + + Exception exception = assertThrows(ExecutionException.class, + maybeSucceeded::get); + Assertions.assertEquals(ReadOnlyRepositoryException.class, exception.getCause().getClass()); + } + + @Test + public void testRequestNotOkException() throws Throwable { + final Call call = mock(Call.class); + final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(capture.capture()); + + final Response response = new okhttp3.Response.Builder() + .code(409) // Conflict + .headers(Headers.of("x-ratelimit-remaining", "0")) + .body( + ResponseBody.create( + MediaType.get("application/json"), + "{\n \"message\": \"Merge Conflict\"\n}" + )) + .message("") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://localhost/").build()) + .build(); + + when(client.newCall(any())).thenReturn(call); + RepositoryClient repoApi = github.createRepositoryClient("testorg", "testrepo"); + + CompletableFuture> future = repoApi.merge("basebranch", "headbranch"); + capture.getValue().onResponse(call, response); + try { + future.get(); + Assertions.fail("Did not throw"); + } catch (ExecutionException e) { + assertThat(e.getCause() instanceof RequestNotOkException, is(true)); + RequestNotOkException e1 = (RequestNotOkException) e.getCause(); + assertThat(e1.statusCode(), is(409)); + assertThat(e1.method(), is("POST")); + assertThat(e1.path(), is("/repos/testorg/testrepo/merges")); + assertThat(e1.headers(), hasEntry("x-ratelimit-remaining", List.of("0"))); + assertThat(e1.getMessage(), containsString("POST")); + assertThat(e1.getMessage(), containsString("/repos/testorg/testrepo/merges")); + assertThat(e1.getMessage(), containsString("Merge Conflict")); + assertThat(e1.getRawMessage(), containsString("Merge Conflict")); + } + } + + @Test + public void testPutConvertsToClass() throws Throwable { + final Call call = mock(Call.class); + final ArgumentCaptor callbackCapture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(callbackCapture.capture()); + + final ArgumentCaptor requestCapture = ArgumentCaptor.forClass(Request.class); + when(client.newCall(requestCapture.capture())).thenReturn(call); + + final Response response = + new okhttp3.Response.Builder() + .code(200) + .body( + ResponseBody.create( + MediaType.get("application/json"), getFixture("repository_invitation.json"))) + .message("") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://localhost/").build()) + .build(); + + CompletableFuture future = github.put("collaborators/", "", + RepositoryInvitation.class); + callbackCapture.getValue().onResponse(call, response); + + RepositoryInvitation invitation = future.get(); + assertThat(requestCapture.getValue().method(), is("PUT")); + assertThat(requestCapture.getValue().url().toString(), is("http://bogus/collaborators/")); + assertThat(invitation.id(), is(1)); + } + + @Test + public void testGetCheckSuites() throws Throwable { + + final Call call = mock(Call.class); + final ArgumentCaptor callbackCapture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(callbackCapture.capture()); + + final Response response = new okhttp3.Response.Builder() + .code(200) + .body( + ResponseBody.create( + MediaType.get("application/json"), getFixture("../checks/check-suites-response.json"))) + .message("") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://localhost/").build()) + .build(); + + when(client.newCall(any())).thenReturn(call); + ChecksClient client = github.createChecksClient("testorg", "testrepo"); + + CompletableFuture future = client.getCheckSuites("sha"); + callbackCapture.getValue().onResponse(call, response); + var result = future.get(); + + assertThat(result.totalCount(), is(1)); + assertThat(result.checkSuites().get(0).app().get().slug().get(), is("octoapp")); + + } + + @Test + void asAppScopedClientGetsUserClientIfOrgClientNotFound() { + var appGithub = GitHubClient.create(client, URI.create("http://bogus"), new byte[]{}, 1); + var githubSpy = spy(appGithub); + + var orgClientMock = mock(OrganisationClient.class); + when(githubSpy.createOrganisationClient("owner")).thenReturn(orgClientMock); + + var appClientMock = mock(GithubAppClient.class); + when(orgClientMock.createGithubAppClient()).thenReturn(appClientMock); + when(appClientMock.getInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); + + var userClientMock = mock(UserClient.class); + when(githubSpy.createUserClient("owner")).thenReturn(userClientMock); + + var appClientMock2 = mock(GithubAppClient.class); + when(userClientMock.createGithubAppClient()).thenReturn(appClientMock2); + + var installationMock = mock(Installation.class); + when(appClientMock2.getUserInstallation()).thenReturn(completedFuture(installationMock)); + when(installationMock.id()).thenReturn(1); + + var maybeScopedClient = githubSpy.asAppScopedClient("owner").toCompletableFuture().join(); + + Assertions.assertTrue(maybeScopedClient.isPresent()); + verify(githubSpy, times(1)).createOrganisationClient("owner"); + verify(githubSpy, times(1)).createUserClient("owner"); + } + + @Test + void asAppScopedClientReturnsEmptyIfNoInstallation() { + var appGithub = GitHubClient.create(client, URI.create("http://bogus"), new byte[]{}, 1); + var githubSpy = spy(appGithub); + + var orgClientMock = mock(OrganisationClient.class); + when(githubSpy.createOrganisationClient("owner")).thenReturn(orgClientMock); + + var appClientMock = mock(GithubAppClient.class); + when(orgClientMock.createGithubAppClient()).thenReturn(appClientMock); + when(appClientMock.getInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); + + var userClientMock = mock(UserClient.class); + when(githubSpy.createUserClient("owner")).thenReturn(userClientMock); + + var appClientMock2 = mock(GithubAppClient.class); + when(userClientMock.createGithubAppClient()).thenReturn(appClientMock2); + + var installationMock = mock(Installation.class); + when(appClientMock2.getUserInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); + when(installationMock.id()).thenReturn(1); + + var maybeScopedClient = githubSpy.asAppScopedClient("owner").toCompletableFuture().join(); + Assertions.assertTrue(maybeScopedClient.isEmpty()); } - } - - @Test - public void testPutConvertsToClass() throws Throwable { - final Call call = mock(Call.class); - final ArgumentCaptor callbackCapture = ArgumentCaptor.forClass(Callback.class); - doNothing().when(call).enqueue(callbackCapture.capture()); - - final ArgumentCaptor requestCapture = ArgumentCaptor.forClass(Request.class); - when(client.newCall(requestCapture.capture())).thenReturn(call); - - final Response response = - new okhttp3.Response.Builder() - .code(200) - .body( - ResponseBody.create( - MediaType.get("application/json"), getFixture("repository_invitation.json"))) - .message("") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://localhost/").build()) - .build(); - - CompletableFuture future = github.put("collaborators/", "", - RepositoryInvitation.class); - callbackCapture.getValue().onResponse(call, response); - - RepositoryInvitation invitation = future.get(); - assertThat(requestCapture.getValue().method(), is("PUT")); - assertThat(requestCapture.getValue().url().toString(), is("http://bogus/collaborators/")); - assertThat(invitation.id(), is(1)); - } - - @Test - public void testGetCheckSuites() throws Throwable { - - final Call call = mock(Call.class); - final ArgumentCaptor callbackCapture = ArgumentCaptor.forClass(Callback.class); - doNothing().when(call).enqueue(callbackCapture.capture()); - - final Response response = new okhttp3.Response.Builder() - .code(200) - .body( - ResponseBody.create( - MediaType.get("application/json"), getFixture("../checks/check-suites-response.json"))) - .message("") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://localhost/").build()) - .build(); - - when(client.newCall(any())).thenReturn(call); - ChecksClient client = github.createChecksClient("testorg", "testrepo"); - - CompletableFuture future = client.getCheckSuites("sha"); - callbackCapture.getValue().onResponse(call, response); - var result = future.get(); - - assertThat(result.totalCount(), is(1)); - assertThat(result.checkSuites().get(0).app().get().slug().get(), is("octoapp")); - - } - - @Test - void asAppScopedClientGetsUserClientIfOrgClientNotFound() { - var appGithub = GitHubClient.create(client, URI.create("http://bogus"), new byte[] {}, 1); - var githubSpy = spy(appGithub); - - var orgClientMock = mock(OrganisationClient.class); - when(githubSpy.createOrganisationClient("owner")).thenReturn(orgClientMock); - - var appClientMock = mock(GithubAppClient.class); - when(orgClientMock.createGithubAppClient()).thenReturn(appClientMock); - when(appClientMock.getInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); - - var userClientMock = mock(UserClient.class); - when(githubSpy.createUserClient("owner")).thenReturn(userClientMock); - - var appClientMock2 = mock(GithubAppClient.class); - when(userClientMock.createGithubAppClient()).thenReturn(appClientMock2); - - var installationMock = mock(Installation.class); - when(appClientMock2.getUserInstallation()).thenReturn(completedFuture(installationMock)); - when(installationMock.id()).thenReturn(1); - - var maybeScopedClient = githubSpy.asAppScopedClient("owner").toCompletableFuture().join(); - - Assertions.assertTrue(maybeScopedClient.isPresent()); - verify(githubSpy, times(1)).createOrganisationClient("owner"); - verify(githubSpy, times(1)).createUserClient("owner"); - } - - @Test - void asAppScopedClientReturnsEmptyIfNoInstallation() { - var appGithub = GitHubClient.create(client, URI.create("http://bogus"), new byte[] {}, 1); - var githubSpy = spy(appGithub); - - var orgClientMock = mock(OrganisationClient.class); - when(githubSpy.createOrganisationClient("owner")).thenReturn(orgClientMock); - - var appClientMock = mock(GithubAppClient.class); - when(orgClientMock.createGithubAppClient()).thenReturn(appClientMock); - when(appClientMock.getInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); - - var userClientMock = mock(UserClient.class); - when(githubSpy.createUserClient("owner")).thenReturn(userClientMock); - - var appClientMock2 = mock(GithubAppClient.class); - when(userClientMock.createGithubAppClient()).thenReturn(appClientMock2); - - var installationMock = mock(Installation.class); - when(appClientMock2.getUserInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); - when(installationMock.id()).thenReturn(1); - - var maybeScopedClient = githubSpy.asAppScopedClient("owner").toCompletableFuture().join(); - Assertions.assertTrue(maybeScopedClient.isEmpty()); - } }