diff --git a/.gitignore b/.gitignore index f9a2b3a8..417741c2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ dependency-reduced-pom.xml # mvn release pom.xml.releaseBackup release.properties + +# macOS +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 73f55abb..c4463be5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ ![lifecycle: beta](https://img.shields.io/badge/lifecycle-beta-509bf5.svg) [![Maven Central](https://img.shields.io/maven-central/v/com.spotify/github-client)](https://mvnrepository.com/artifact/com.spotify/github-client) - # github-java-client A small Java library for talking to GitHub/GitHub Enterprise and interacting with projects. @@ -74,6 +73,7 @@ log.info(repositoryClient.getCommit("sha").get().htmlUrl()); Another example of the mirrored structure is that some of the APIs are nested under a parent API. For example, endpoints related to check runs or issues are nested under the Repository client: + ```java final ChecksClient checksClient = repositoryClient.createChecksApiClient(); checksClient.createCheckRun(CHECK_RUN_REQUEST); @@ -85,11 +85,51 @@ issueClient.createComment(ISSUE_ID, "comment body") ``` And endpoints related to teams and memberships are nested under the Organisation client: + ```java final TeamClient teamClient = organisationClient.createTeamClient(); teamClient.getMembership("username"); ``` +## Tracing + +The GitHub client supports tracing via both OpenCensus and OpenTelemetry. Since OpenCensus is deprecated, we recommend +using OpenTelemetry. Using OpenTelemetry also enables context propagation when using this library. +To enable tracing, you need to provide a tracer when initializing the client. + +### OpenTelemetry + +```java +import com.spotify.github.tracing.opentelemetry.OpenTelemetryTracer; + +final GitHubClient githubClient = + GitHubClient.create(baseUri, accessToken) + // Uses GlobalOpenTelemetry.get() to fetch the default tracer + .withTracer(new OpenTelemetryTracer()); +``` + +You can also provide a custom `OpenTelemetry` object if you want to use a specific one. + +```java +import com.spotify.github.tracing.opentelemetry.OpenTelemetryTracer; + +final GitHubClient githubClient = + GitHubClient.create(baseUri, accessToken) + // Uses custom openTelemetry object to fetch the tracer + .withTracer(new OpenTelemetryTracer(openTelemetry)); +``` + +### OpenCensus + +```java +import com.spotify.github.tracing.opencensus.OpenCensusTracer; + +final GitHubClient githubClient = + GitHubClient.create(baseUri, accessToken) + // Uses Tracing.getTracer() to fetch the default tracer + .withTracer(new OpenCensusTracer()); +``` + ## Supported Java versions This library is written and published with Java version 11. In our CI workflows, we execute @@ -107,6 +147,7 @@ mvn clean verify If you are a maintainer, you can release a new version by just triggering the workflow [prepare-release](./.github/workflows/prepare-release.yml) through the [web UI](https://github.com/spotify/github-java-client/actions/workflows/prepare-release.yml). + - Select whether the new release should be a `major`, `minor` or `patch` release - Trigger the release preparation on the `master` branch - Pushes of this workflow will trigger runs of the diff --git a/pom.xml b/pom.xml index d91c833d..231d6155 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ Spotify AB - http://www.spotify.com + https://www.spotify.com @@ -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,30 @@ opencensus-api ${opencensus.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-sdk-testing + + + io.opentelemetry.instrumentation + opentelemetry-okhttp-3.0 + 2.8.0-alpha + + + commons-io + commons-io + 2.7 + compile + io.jsonwebtoken jjwt-impl diff --git a/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java b/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java index 1bbf15ec..e513a58e 100644 --- a/src/main/java/com/spotify/github/opencensus/OpenCensusSpan.java +++ b/src/main/java/com/spotify/github/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. @@ -19,45 +19,20 @@ */ package com.spotify.github.opencensus; -import static java.util.Objects.requireNonNull; -import com.spotify.github.Span; -import com.spotify.github.v3.exceptions.RequestNotOkException; -import io.opencensus.trace.AttributeValue; -import io.opencensus.trace.Status; -class OpenCensusSpan implements Span { +import io.opencensus.trace.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) { - this.span = requireNonNull(span); - } - - @Override - public Span success() { - span.setStatus(Status.OK); - return this; - } - - @Override - public Span failure(final Throwable t) { - if (t instanceof RequestNotOkException) { - RequestNotOkException ex = (RequestNotOkException) t; - span.putAttribute("http.status_code", AttributeValue.longAttributeValue(ex.statusCode())); - span.putAttribute("message", AttributeValue.stringAttributeValue(ex.getRawMessage())); - if (ex.statusCode() - INTERNAL_SERVER_ERROR >= 0) { - span.putAttribute("error", AttributeValue.booleanAttributeValue(true)); - } - } - span.setStatus(Status.UNKNOWN); - return this; - } - - @Override - public void close() { - span.end(); - } +/** + * OpenCensusSpan is a wrapper around OpenCensus Span. This class is kept for backward + * compatibility. + * + * @deprecated This class has been moved to the package com.spotify.github.tracing.opencensus. + * Please use com.spotify.github.tracing.opencensus.OpenCensusSpan instead. + */ +@Deprecated +public class OpenCensusSpan extends com.spotify.github.tracing.opencensus.OpenCensusSpan { + public OpenCensusSpan(final Span span) { + super(span); + } + // This class is kept for backward compatibility } - diff --git a/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java b/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java index d4834b34..6f1b00c5 100644 --- a/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java +++ b/src/main/java/com/spotify/github/opencensus/OpenCensusTracer.java @@ -2,14 +2,14 @@ * -\-\- * github-api * -- - * Copyright (C) 2021 Spotify AB + * 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. @@ -20,52 +20,14 @@ package com.spotify.github.opencensus; -import com.spotify.github.Span; -import com.spotify.github.Tracer; -import io.opencensus.trace.Tracing; - -import java.util.concurrent.CompletionStage; - -import static io.opencensus.trace.AttributeValue.stringAttributeValue; -import static io.opencensus.trace.Span.Kind.CLIENT; -import static java.util.Objects.requireNonNull; - -public class OpenCensusTracer implements Tracer { - - 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( - 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(); - - ocSpan.putAttribute("component", stringAttributeValue("github-api-client")); - ocSpan.putAttribute("peer.service", stringAttributeValue("github")); - ocSpan.putAttribute("http.url", stringAttributeValue(path)); - 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(); - }); - - return span; - } +/** + * OpenCensusTracer is a wrapper around OpenCensus Tracer. This class is kept for backward + * compatibility. + * + * @deprecated This class has been moved to the package com.spotify.github.tracing.opencensus. + * Please use com.spotify.github.tracing.opencensus.OpenCensusTracer instead. + */ +@Deprecated +public class OpenCensusTracer extends com.spotify.github.tracing.opencensus.OpenCensusTracer { + // This class is kept for backward compatibility } 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..f58037a7 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/BaseTracer.java @@ -0,0 +1,69 @@ +/*- + * -\-\- + * 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, null); + } + + @Override + public Span span(final Request request, final CompletionStage future) { + return internalSpan(request, future); + } + + protected abstract Span internalSpan( + String path, + String method, + CompletionStage future); + + protected abstract Span internalSpan( + Request request, + 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 64% rename from src/main/java/com/spotify/github/Span.java rename to src/main/java/com/spotify/github/tracing/Span.java index 70b2e939..f5c1abdb 100644 --- a/src/main/java/com/spotify/github/Span.java +++ b/src/main/java/com/spotify/github/tracing/Span.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,16 +18,25 @@ * -/-/- */ -package com.spotify.github; +package com.spotify.github.tracing; public interface Span extends AutoCloseable { - Span success(); + Span success(); - Span failure(Throwable t); + Span failure(Throwable t); - /** Close span. Must be called for any opened span. */ - @Override - void close(); -} + /** Close span. Must be called for any opened span. */ + @Override + void close(); + + Span addTag(String key, String value); + + Span addTag(String key, boolean value); + Span addTag(String key, long value); + + Span addTag(String key, double value); + + Span addEvent(String description); +} 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..6c783067 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/TraceHelper.java @@ -0,0 +1,66 @@ +/*- + * -\-\- + * 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.v3.exceptions.RequestNotOkException; + +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"; + + public static final int NOT_FOUND = 404; + public static final int INTERNAL_SERVER_ERROR = 500; + + // Private constructor to prevent instantiation + private TraceHelper() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static Span failSpan(final Span span, final Throwable t) { + if (t instanceof RequestNotOkException) { + RequestNotOkException ex = (RequestNotOkException) t; + span.addTag(TraceHelper.TraceTags.HTTP_STATUS_CODE, ex.statusCode()) + .addTag(TraceHelper.TraceTags.ERROR_MESSAGE, ex.getRawMessage()); + if (ex.statusCode() - INTERNAL_SERVER_ERROR >= 0) { + span.addTag(TraceHelper.TraceTags.ERROR, true); + } + } else { + if (t != null) { + span.addTag(TraceHelper.TraceTags.ERROR_MESSAGE, t.getMessage()); + } + span.addTag(TraceHelper.TraceTags.ERROR, true); + } + return span; + } + + public static class TraceTags { + public static final String COMPONENT = "component"; + public static final String PEER_SERVICE = "peer.service"; + public static final String HTTP_URL = "http.url"; + public static final String HTTP_METHOD = "method"; + public static final String HTTP_STATUS_CODE = "http.status_code"; + public static final String HTTP_STATUS_MESSAGE = "http.status_message"; + public static final String ERROR_MESSAGE = "message"; + public static final String ERROR = "error"; + } +} diff --git a/src/main/java/com/spotify/github/Tracer.java b/src/main/java/com/spotify/github/tracing/Tracer.java similarity index 61% rename from src/main/java/com/spotify/github/Tracer.java rename to src/main/java/com/spotify/github/tracing/Tracer.java index 5d5bcd9c..a7d704c7 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,33 @@ * -/-/- */ -package com.spotify.github; +package com.spotify.github.tracing; + +import okhttp3.Call; +import okhttp3.OkHttpClient; +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); + + Span span( + Request request, CompletionStage future); + + void attachSpanToFuture(Span span, CompletionStage future); + + Call.Factory createTracedClient(OkHttpClient client); } diff --git a/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusSpan.java b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusSpan.java new file mode 100644 index 00000000..755d0a0f --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusSpan.java @@ -0,0 +1,84 @@ +/*- + * -\-\- + * github-api + * -- + * 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.opencensus; + +import static com.spotify.github.tracing.TraceHelper.failSpan; +import static java.util.Objects.requireNonNull; + +import com.spotify.github.tracing.Span; +import io.opencensus.trace.AttributeValue; +import io.opencensus.trace.Status; + +public class OpenCensusSpan implements Span { + private final io.opencensus.trace.Span span; + + public OpenCensusSpan(final io.opencensus.trace.Span span) { + this.span = requireNonNull(span); + } + + @Override + public Span success() { + span.setStatus(Status.OK); + return this; + } + + @Override + public Span failure(final Throwable t) { + failSpan(this, t); + span.setStatus(Status.UNKNOWN); + return this; + } + + @Override + public void close() { + span.end(); + } + + @Override + public Span addTag(final String key, final String value) { + this.span.putAttribute(key, AttributeValue.stringAttributeValue(value)); + return this; + } + + @Override + public Span addTag(final String key, final boolean value) { + this.span.putAttribute(key, AttributeValue.booleanAttributeValue(value)); + return this; + } + + @Override + public Span addTag(final String key, final long value) { + this.span.putAttribute(key, AttributeValue.longAttributeValue(value)); + return this; + } + + @Override + public Span addTag(final String key, final double value) { + this.span.putAttribute(key, AttributeValue.doubleAttributeValue(value)); + return this; + } + + @Override + public Span addEvent(final String description) { + this.span.addAnnotation(description); + return this; + } +} diff --git a/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java new file mode 100644 index 00000000..51f35825 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java @@ -0,0 +1,109 @@ +/*- + * -\-\- + * 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.opencensus; + +import static io.opencensus.trace.Span.Kind.CLIENT; +import static java.util.Objects.requireNonNull; + +import com.spotify.github.tracing.BaseTracer; +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.TraceHelper; +import io.opencensus.trace.Tracing; +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import okhttp3.*; +import org.jetbrains.annotations.NotNull; + +/** Tracer implementation using OpenCensus. */ +public class OpenCensusTracer extends BaseTracer { + + private static final io.opencensus.trace.Tracer TRACER = Tracing.getTracer(); + + @SuppressWarnings("MustBeClosedChecker") + protected Span internalSpan( + final String path, final String method, final CompletionStage future) { + requireNonNull(path); + + final io.opencensus.trace.Span ocSpan = + TRACER.spanBuilder("GitHub Request").setSpanKind(CLIENT).startSpan(); + + final Span span = + new OpenCensusSpan(ocSpan) + .addTag(TraceHelper.TraceTags.COMPONENT, "github-api-client") + .addTag(TraceHelper.TraceTags.PEER_SERVICE, "github") + .addTag(TraceHelper.TraceTags.HTTP_URL, path) + .addTag(TraceHelper.TraceTags.HTTP_METHOD, method); + + if (future != null) { + attachSpanToFuture(span, future); + } + + return span; + } + + @Override + protected Span internalSpan(final Request request, final CompletionStage future) { + requireNonNull(request); + return internalSpan(request.url().toString(), request.method(), future); + } + + @Override + public Call.Factory createTracedClient(final OkHttpClient client) { + return new Call.Factory() { + @NotNull + @Override + public Call newCall(@NotNull final Request request) { + CompletableFuture future = new CompletableFuture<>(); + Span span = + internalSpan(request, future) + .addTag(TraceHelper.TraceTags.HTTP_URL, request.url().toString()); + OkHttpClient.Builder okBuilder = client.newBuilder(); + okBuilder + .networkInterceptors() + .add( + 0, + new Interceptor() { + @NotNull + @Override + public Response intercept(@NotNull final Chain chain) throws IOException { + try { + Response response = chain.proceed(chain.request()); + span.addTag(TraceHelper.TraceTags.HTTP_STATUS_CODE, response.code()) + .addTag(TraceHelper.TraceTags.HTTP_STATUS_MESSAGE, response.message()) + .success(); + future.complete(response); + return response; + } catch (Exception ex) { + span.failure(ex); + future.completeExceptionally(ex); + throw ex; + } finally { + span.close(); + } + } + }); + + return okBuilder.build().newCall(request); + } + }; + } +} 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..66b1c493 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetrySpan.java @@ -0,0 +1,86 @@ +/*- + * -\-\- + * 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 static com.spotify.github.tracing.TraceHelper.failSpan; +import static java.util.Objects.requireNonNull; + +import com.spotify.github.tracing.Span; +import io.opentelemetry.api.trace.StatusCode; + +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) { + failSpan(this, t); + span.setStatus(StatusCode.ERROR); + return this; + } + + @Override + public void close() { + span.end(); + } + + @Override + public Span addTag(final String key, final String value) { + this.span.setAttribute(key, value); + return this; + } + + @Override + public Span addTag(final String key, final boolean value) { + this.span.setAttribute(key, value); + return this; + } + + @Override + public Span addTag(final String key, final long value) { + this.span.setAttribute(key, value); + return this; + } + + @Override + public Span addTag(final String key, final double value) { + this.span.setAttribute(key, value); + return this; + } + + @Override + public Span addEvent(final String description) { + this.span.addEvent(description); + return this; + } +} 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..8e354c18 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java @@ -0,0 +1,131 @@ +/*- + * -\-\- + * 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.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.instrumentation.okhttp.v3_0.OkHttpTelemetry; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.CompletionStage; + +import static java.util.Objects.requireNonNull; + +/** Tracer implementation using OpenTelemetry. */ +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()); + } + + /** + * Create a new span for the given path and method. + * + * @param path The path of the request. + * @param method The method of the request. + * @param future The future to attach the span to. + * @return The created span. + */ + @SuppressWarnings("MustBeClosedChecker") + protected Span internalSpan( + final String path, final String method, final CompletionStage future) { + requireNonNull(path); + + Context context = Context.current(); + + final io.opentelemetry.api.trace.Span otSpan = + tracer + .spanBuilder("GitHub Request") + .setParent(context) + .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; + } + + /** + * Create a new span for the given request. + * + * @param request The request to create a span for. + * @param future The future to attach the span to. + * @return The created span. + */ + @Override + protected Span internalSpan(final Request request, final CompletionStage future) { + requireNonNull(request); + // Extract the context from the request headers. + Context context = + W3CTraceContextPropagator.getInstance() + .extract( + Context.current(), + request, + new TextMapGetter<>() { + @Override + public Iterable keys(@NotNull final Request carrier) { + return carrier.headers().names(); + } + + @Nullable + @Override + public String get(@Nullable final Request carrier, @NotNull final String key) { + if (carrier == null) { + return null; + } + return carrier.header(key); + } + }); + context.makeCurrent(); + return internalSpan(request.url().toString(), request.method(), future); + } + + @Override + public Call.Factory createTracedClient(final OkHttpClient client) { + return OkHttpTelemetry.builder(openTelemetry).build().newCallFactory(client); + } +} 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 c6884b27..237fd3c0 100644 --- a/src/main/java/com/spotify/github/v3/clients/GitHubClient.java +++ b/src/main/java/com/spotify/github/v3/clients/GitHubClient.java @@ -20,13 +20,11 @@ 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.async.Async; import com.spotify.github.jackson.Json; +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.Tracer; import com.spotify.github.v3.Team; import com.spotify.github.v3.User; import com.spotify.github.v3.checks.AccessToken; @@ -40,14 +38,18 @@ 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.jetbrains.annotations.NotNull; +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; @@ -60,319 +62,329 @@ 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_COMMENT_REACTION_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_REPOSITORY = - new TypeReference<>() {}; - static final TypeReference> LIST_COMMIT_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_REVIEW_TYPE_REFERENCE = new TypeReference<>() {}; - static final TypeReference LIST_REVIEW_REQUEST_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_STATUS_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_FOLDERCONTENT_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_PR_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_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_COMMENT_REACTION_TYPE_REFERENCE = + new TypeReference<>() {}; + static final TypeReference> LIST_REPOSITORY = + new TypeReference<>() { + }; + static final TypeReference> LIST_COMMIT_TYPE_REFERENCE = + new TypeReference<>() { + }; + static final TypeReference> LIST_REVIEW_TYPE_REFERENCE = new TypeReference<>() { + }; + static final TypeReference LIST_REVIEW_REQUEST_TYPE_REFERENCE = + new TypeReference<>() { + }; + static final TypeReference> LIST_STATUS_TYPE_REFERENCE = + new TypeReference<>() { + }; + static final TypeReference> LIST_FOLDERCONTENT_TYPE_REFERENCE = + new TypeReference<>() { + }; + static final TypeReference> LIST_PR_TYPE_REFERENCE = + new TypeReference<>() { + }; + static final TypeReference> LIST_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 Call.Factory callFactory; + private final String token; + + private final byte[] privateKey; + private final Integer appId; + private final Integer installationId; + + private final Map installationTokens; + + private GitHubClient( + final 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, @@ -405,587 +417,591 @@ 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)) { + if (this.callFactory == null) { + this.callFactory = this.tracer.createTracedClient(this.client); + } + final Call call = this.callFactory.newCall(request); + + final CompletableFuture future = new CompletableFuture<>(); + + // avoid multiple redirects + final AtomicBoolean redirected = new AtomicBoolean(false); + + call.enqueue( + new Callback() { + @Override + public void onFailure(@NotNull final Call call, final IOException e) { + future.completeExceptionally(e); + } + + @Override + public void onResponse(@NotNull 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..60d64271 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,18 @@ */ 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.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.jetbrains.annotations.NotNull; 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 +47,37 @@ public Span failure(final Throwable t) { } @Override - public void close() {} + public void close() { + } + + @Override + public Span addTag(final String key, final String value) { + return this; + } + + @Override + public Span addTag(final String key, final boolean value) { + return this; + } + + @Override + public Span addTag(final String key, final long value) { + return this; + } + + @Override + public Span addTag(final String key, final double value) { + return this; + } + + @Override + public Span addEvent(final String description) { + return this; + } }; - private NoopTracer() {} + private NoopTracer() { + } @Override public Span span( @@ -53,5 +87,46 @@ 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 Span span(final Request request, final CompletionStage future) { + 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(); + }); + } + + @Override + public Call.Factory createTracedClient(final OkHttpClient client) { + return new Call.Factory() { + @NotNull + @Override + public Call newCall(@NotNull final Request request) { + return client.newCall(request); + } + }; + } + } diff --git a/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java b/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java deleted file mode 100644 index c6b5d94d..00000000 --- a/src/test/java/com/spotify/github/opencensus/OpenCensusSpanTest.java +++ /dev/null @@ -1,69 +0,0 @@ -/*- - * -\-\- - * 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.opencensus; - -import com.spotify.github.Span; -import com.spotify.github.v3.exceptions.RequestNotOkException; -import io.opencensus.trace.AttributeValue; -import io.opencensus.trace.Status; -import java.util.Collections; -import org.junit.jupiter.api.Test; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class OpenCensusSpanTest { - private io.opencensus.trace.Span wrapped = mock(io.opencensus.trace.Span.class); - - @Test - public void succeed() { - final Span span = new OpenCensusSpan(wrapped); - span.success(); - span.close(); - - verify(wrapped).setStatus(Status.OK); - verify(wrapped).end(); - } - - @Test - public void fail() { - final Span span = new OpenCensusSpan(wrapped); - span.failure(new RequestNotOkException("method", "path", 404, "Not found", Collections.emptyMap())); - span.close(); - - verify(wrapped).setStatus(Status.UNKNOWN); - verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(404)); - verify(wrapped).end(); - } - - @Test - public void failOnServerError() { - final Span span = new OpenCensusSpan(wrapped); - span.failure(new RequestNotOkException("method", "path", 500, "Internal Server Error", Collections.emptyMap())); - span.close(); - - verify(wrapped).setStatus(Status.UNKNOWN); - verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(500)); - verify(wrapped).putAttribute("error", AttributeValue.booleanAttributeValue(true)); - verify(wrapped).end(); - } - -} diff --git a/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java b/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java deleted file mode 100644 index 27b5c52b..00000000 --- a/src/test/java/com/spotify/github/opencensus/OpenCensusTracerTest.java +++ /dev/null @@ -1,130 +0,0 @@ -/*- - * -\-\- - * 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.opencensus; - - -import io.grpc.Context; -import io.opencensus.trace.*; -import io.opencensus.trace.config.TraceConfig; -import io.opencensus.trace.config.TraceParams; -import io.opencensus.trace.export.SpanData; -import io.opencensus.trace.samplers.Samplers; -import io.opencensus.trace.unsafe.ContextUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -import static io.opencensus.trace.AttributeValue.stringAttributeValue; -import static org.junit.jupiter.api.Assertions.assertEquals; - -public class OpenCensusTracerTest { - - - private final String rootSpanName = "root span"; - private TestExportHandler spanExporterHandler; - - /** - * 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<>(); - OpenCensusTracer tracer = new OpenCensusTracer(); - - 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.getContext().getTraceId(), inner.getContext().getTraceId()); - assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); - final Map attributes = inner.getAttributes().getAttributeMap(); - assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); - assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); - assertEquals(stringAttributeValue("path"), attributes.get("http.url")); - assertEquals(stringAttributeValue("GET"), attributes.get("method")); - assertEquals(Status.OK, inner.getStatus()); - } - - @Test - public void testTrace_CompletionStage_Fails() throws Exception { - Span rootSpan = startRootSpan(); - final CompletableFuture future = new CompletableFuture<>(); - OpenCensusTracer tracer = new OpenCensusTracer(); - - 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.getContext().getTraceId(), inner.getContext().getTraceId()); - assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); - final Map attributes = inner.getAttributes().getAttributeMap(); - assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); - assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); - assertEquals(stringAttributeValue("path"), attributes.get("http.url")); - assertEquals(stringAttributeValue("POST"), attributes.get("method")); - assertEquals(Status.UNKNOWN, inner.getStatus()); - } - - private Span startRootSpan() { - Span rootSpan = Tracing.getTracer().spanBuilder(rootSpanName).startSpan(); - Context context = ContextUtils.withValue(Context.current(), rootSpan); - context.attach(); - return rootSpan; - } - - private SpanData findSpan(final List spans, final String name) { - return spans.stream().filter(s -> s.getName().equals(name)).findFirst().get(); - } - - @BeforeEach - public void setUpExporter() { - spanExporterHandler = new TestExportHandler(); - Tracing.getExportComponent().getSpanExporter().registerHandler("test", spanExporterHandler); - } - - @BeforeAll - public static void setupTracing() { - final TraceConfig traceConfig = Tracing.getTraceConfig(); - final Sampler sampler = Samplers.alwaysSample(); - final TraceParams newParams = - traceConfig.getActiveTraceParams().toBuilder().setSampler(sampler).build(); - traceConfig.updateActiveTraceParams(newParams); - } -} \ No newline at end of file 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/tracing/OpenCensusSpanTest.java b/src/test/java/com/spotify/github/tracing/OpenCensusSpanTest.java new file mode 100644 index 00000000..f53d4505 --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OpenCensusSpanTest.java @@ -0,0 +1,160 @@ +/*- + * -\-\- + * 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.opencensus.OpenCensusSpan; +import com.spotify.github.v3.exceptions.RequestNotOkException; +import io.opencensus.trace.AttributeValue; +import io.opencensus.trace.Status; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class OpenCensusSpanTest { + private final io.opencensus.trace.Span wrapped = mock(io.opencensus.trace.Span.class); + + @Test + public void succeed() { + final Span span = new OpenCensusSpan(wrapped); + span.success(); + span.close(); + + verify(wrapped).setStatus(Status.OK); + verify(wrapped).end(); + } + + @Test + public void fail() { + final Span span = new OpenCensusSpan(wrapped); + span.failure( + new RequestNotOkException("method", "path", 404, "Not found", Collections.emptyMap())); + span.close(); + + verify(wrapped).setStatus(Status.UNKNOWN); + verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(404)); + verify(wrapped).end(); + } + + @Test + public void failOnServerError() { + final Span span = new OpenCensusSpan(wrapped); + span.failure( + new RequestNotOkException( + "method", "path", 500, "Internal Server Error", Collections.emptyMap())); + span.close(); + + verify(wrapped).setStatus(Status.UNKNOWN); + verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(500)); + verify(wrapped).putAttribute("error", AttributeValue.booleanAttributeValue(true)); + verify(wrapped).end(); + } + + @Test + public void addTags() { + final Span span = new OpenCensusSpan(wrapped); + span.addTag("key", "value"); + span.addTag("key", true); + span.addTag("key", 42L); + span.close(); + + verify(wrapped).putAttribute("key", AttributeValue.stringAttributeValue("value")); + verify(wrapped).putAttribute("key", AttributeValue.booleanAttributeValue(true)); + verify(wrapped).putAttribute("key", AttributeValue.longAttributeValue(42L)); + verify(wrapped).end(); + } + + @Test + public void addEvent() { + final Span span = new OpenCensusSpan(wrapped); + span.addEvent("description"); + span.close(); + + verify(wrapped).addAnnotation("description"); + verify(wrapped).end(); + } + + @Test + @SuppressWarnings("deprecation") + public void succeedDeprecated() { + final Span span = new com.spotify.github.opencensus.OpenCensusSpan(wrapped); + span.success(); + span.close(); + + verify(wrapped).setStatus(Status.OK); + verify(wrapped).end(); + } + + @Test + @SuppressWarnings("deprecation") + public void failDeprecated() { + final Span span = new com.spotify.github.opencensus.OpenCensusSpan(wrapped); + span.failure( + new RequestNotOkException("method", "path", 404, "Not found", Collections.emptyMap())); + span.close(); + + verify(wrapped).setStatus(Status.UNKNOWN); + verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(404)); + verify(wrapped).end(); + } + + @Test + @SuppressWarnings("deprecation") + public void failOnServerErrorDeprecated() { + final Span span = new com.spotify.github.opencensus.OpenCensusSpan(wrapped); + span.failure( + new RequestNotOkException( + "method", "path", 500, "Internal Server Error", Collections.emptyMap())); + span.close(); + + verify(wrapped).setStatus(Status.UNKNOWN); + verify(wrapped).putAttribute("http.status_code", AttributeValue.longAttributeValue(500)); + verify(wrapped).putAttribute("error", AttributeValue.booleanAttributeValue(true)); + verify(wrapped).end(); + } + + @Test + @SuppressWarnings("deprecation") + public void addTagsDeprecated() { + final Span span = new com.spotify.github.opencensus.OpenCensusSpan(wrapped); + span.addTag("key", "value"); + span.addTag("key", true); + span.addTag("key", 42L); + span.close(); + + verify(wrapped).putAttribute("key", AttributeValue.stringAttributeValue("value")); + verify(wrapped).putAttribute("key", AttributeValue.booleanAttributeValue(true)); + verify(wrapped).putAttribute("key", AttributeValue.longAttributeValue(42L)); + verify(wrapped).end(); + } + + @Test + @SuppressWarnings("deprecation") + public void addEventDeprecated() { + final Span span = new com.spotify.github.opencensus.OpenCensusSpan(wrapped); + span.addEvent("description"); + span.close(); + + verify(wrapped).addAnnotation("description"); + verify(wrapped).end(); + } +} diff --git a/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java b/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java new file mode 100644 index 00000000..2d0b6fcf --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java @@ -0,0 +1,185 @@ +/*- + * -\-\- + * 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.opencensus.OpenCensusTracer; +import io.grpc.Context; +import io.opencensus.trace.Span; +import io.opencensus.trace.*; +import io.opencensus.trace.config.TraceConfig; +import io.opencensus.trace.config.TraceParams; +import io.opencensus.trace.export.SpanData; +import io.opencensus.trace.samplers.Samplers; +import io.opencensus.trace.unsafe.ContextUtils; +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static io.opencensus.trace.AttributeValue.stringAttributeValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class OpenCensusTracerTest { + + private final String rootSpanName = "root span"; + private OcTestExportHandler spanExporterHandler; + + /** + * 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. + */ + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + public void traceCompletionStageSimple(final String requestMethod) throws Exception { + io.opencensus.trace.Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenCensusTracer tracer = new OpenCensusTracer(); + + tracer.span("path", requestMethod, 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.getContext().getTraceId(), inner.getContext().getTraceId()); + assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); + final Map attributes = inner.getAttributes().getAttributeMap(); + assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); + assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); + assertEquals(stringAttributeValue("path"), attributes.get("http.url")); + assertEquals(stringAttributeValue(requestMethod), attributes.get("method")); + assertEquals(Status.OK, inner.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + public void traceCompletionStageFails(final String requestMethod) throws Exception { + io.opencensus.trace.Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenCensusTracer tracer = new OpenCensusTracer(); + + tracer.span("path", requestMethod, 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.getContext().getTraceId(), inner.getContext().getTraceId()); + assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); + final Map attributes = inner.getAttributes().getAttributeMap(); + assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); + assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); + assertEquals(stringAttributeValue("path"), attributes.get("http.url")); + assertEquals(stringAttributeValue(requestMethod), attributes.get("method")); + assertEquals(Status.UNKNOWN, inner.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + public void traceCompletionStageWithRequest(final String requestMethod) throws Exception { + io.opencensus.trace.Span rootSpan = startRootSpan(); + OpenCensusTracer tracer = new OpenCensusTracer(); + final CompletableFuture future = new CompletableFuture<>(); + Request mockRequest = mock(Request.class); + when(mockRequest.url()) + .thenReturn(HttpUrl.parse("https://api.github.com/repos/spotify/github-java-client")); + when(mockRequest.method()).thenReturn(requestMethod); + + try (com.spotify.github.tracing.Span span = tracer.span(mockRequest)) { + tracer.attachSpanToFuture(span, 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.getContext().getTraceId(), inner.getContext().getTraceId()); + assertEquals(root.getContext().getSpanId(), inner.getParentSpanId()); + final Map attributes = inner.getAttributes().getAttributeMap(); + assertEquals(stringAttributeValue("github-api-client"), attributes.get("component")); + assertEquals(stringAttributeValue("github"), attributes.get("peer.service")); + assertEquals( + stringAttributeValue("https://api.github.com/repos/spotify/github-java-client"), + attributes.get("http.url")); + assertEquals(stringAttributeValue(requestMethod), attributes.get("method")); + assertEquals(Status.OK, inner.getStatus()); + } + + @Test + public void createTracedClient() { + OpenCensusTracer tracer = new OpenCensusTracer(); + OkHttpClient client = new OkHttpClient.Builder().build(); + Call.Factory callFactory = tracer.createTracedClient(client); + assertNotNull(callFactory); + } + + @SuppressWarnings("deprecation") + private io.opencensus.trace.Span startRootSpan() { + Span rootSpan = Tracing.getTracer().spanBuilder(rootSpanName).startSpan(); + Context context = ContextUtils.withValue(Context.current(), rootSpan); + context.attach(); + return rootSpan; + } + + private SpanData findSpan(final List spans, final String name) { + return spans.stream().filter(s -> s.getName().equals(name)).findFirst().get(); + } + + @BeforeEach + public void setUpExporter() { + spanExporterHandler = new OcTestExportHandler(); + Tracing.getExportComponent().getSpanExporter().registerHandler("test", spanExporterHandler); + } + + @BeforeAll + public static void setupTracing() { + final TraceConfig traceConfig = Tracing.getTraceConfig(); + final Sampler sampler = Samplers.alwaysSample(); + final TraceParams newParams = + traceConfig.getActiveTraceParams().toBuilder().setSampler(sampler).build(); + traceConfig.updateActiveTraceParams(newParams); + } +} 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..41223174 --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OpenTelemetrySpanTest.java @@ -0,0 +1,117 @@ +/*- + * -\-\- + * 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.*; + +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.ERROR); + 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.ERROR); + verify(wrapped).setAttribute("http.status_code", 500); + verify(wrapped).setAttribute("error", true); + verify(wrapped).end(); + } + + @Test + public void failWithNullThrowable() { + final Span span = new OpenTelemetrySpan(wrapped); + span.failure(null); + span.close(); + + verify(wrapped).setStatus(StatusCode.ERROR); + verify(wrapped, never()).setAttribute(anyString(), any()); + verify(wrapped).end(); + } + + @Test + public void failWithNonRequestNotOkException() { + final Span span = new OpenTelemetrySpan(wrapped); + span.failure(new RuntimeException("Unexpected error")); + span.close(); + + verify(wrapped).setStatus(StatusCode.ERROR); + verify(wrapped, never()).setAttribute("http.status_code", 404); + verify(wrapped).setAttribute("error", true); + verify(wrapped).end(); + } + + @Test + public void addTags() { + final Span span = new OpenTelemetrySpan(wrapped); + span.addTag("key", "value"); + span.addTag("key", true); + span.addTag("key", 42L); + span.close(); + + verify(wrapped).setAttribute("key", "value"); + verify(wrapped).setAttribute("key", true); + verify(wrapped).setAttribute("key", 42L); + } + + @Test + public void addEvent() { + final Span span = new OpenTelemetrySpan(wrapped); + span.addEvent("description"); + span.close(); + + verify(wrapped).addEvent("description"); + 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..3cf82360 --- /dev/null +++ b/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java @@ -0,0 +1,199 @@ +/*- + * -\-\- + * 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 okhttp3.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class OpenTelemetryTracerTest { + + private final String rootSpanName = "root span"; + private static OtTestExportHandler spanExporterHandler; + private final OpenTelemetry openTelemetry = GlobalOpenTelemetry.get(); + private final 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. + */ + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + public void traceCompletionStageSimple(final String requestMethod) throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenTelemetryTracer tracer = new OpenTelemetryTracer(); + + tracer.span("path", requestMethod, 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(requestMethod, attributes.get(AttributeKey.stringKey("method"))); + assertEquals(StatusCode.OK, inner.getStatus().getStatusCode()); + } + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + public void traceCompletionStageFails(final String requestMethod) throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenTelemetryTracer tracer = new OpenTelemetryTracer(); + + tracer.span("path", requestMethod, 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(requestMethod, attributes.get(AttributeKey.stringKey("method"))); + assertEquals(StatusCode.ERROR, inner.getStatus().getStatusCode()); + } + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE"}) + public void traceCompletionStageWithRequest(final String requestMethod) throws Exception { + Span rootSpan = startRootSpan(); + final CompletableFuture future = new CompletableFuture<>(); + OpenTelemetryTracer tracer = new OpenTelemetryTracer(); + Request mockRequest = mock(Request.class); + when(mockRequest.url()) + .thenReturn(HttpUrl.parse("https://api.github.com/repos/spotify/github-java-client")); + when(mockRequest.method()).thenReturn(requestMethod); + + try (com.spotify.github.tracing.Span span = tracer.span(mockRequest)) { + tracer.attachSpanToFuture(span, 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( + "https://api.github.com/repos/spotify/github-java-client", + attributes.get(AttributeKey.stringKey("http.url"))); + assertEquals(requestMethod, attributes.get(AttributeKey.stringKey("method"))); + assertEquals(StatusCode.OK, inner.getStatus().getStatusCode()); + } + + @Test + public void createTracedClient() throws IOException { + OpenTelemetryTracer tracer = new OpenTelemetryTracer(openTelemetry); + OkHttpClient.Builder mockBuilder = mock(OkHttpClient.Builder.class); + OkHttpClient mockClient = mock(OkHttpClient.class); + LinkedList interceptors = new LinkedList<>(); + when(mockClient.newBuilder()).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockClient); + when(mockBuilder.interceptors()).thenReturn(interceptors); + when(mockBuilder.networkInterceptors()).thenReturn(interceptors); + Call.Factory callFactory = tracer.createTracedClient(mockClient); + assertNotNull(callFactory); + assertEquals( + "class io.opentelemetry.instrumentation.okhttp.v3_0.TracingCallFactory", + callFactory.getClass().toString()); + assertEquals(3, interceptors.size()); + } + + 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(); + } +} 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 5a710fdb..5a9edc45 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,17 +33,15 @@ 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; import com.spotify.github.v3.exceptions.RequestNotOkException; import com.spotify.github.v3.repos.CommitItem; import com.spotify.github.v3.repos.RepositoryInvitation; -import com.spotify.github.v3.workflows.WorkflowsResponse; -import com.spotify.github.v3.workflows.WorkflowsState; -import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -52,6 +50,9 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; + +import com.spotify.github.v3.workflows.WorkflowsResponse; +import com.spotify.github.v3.workflows.WorkflowsState; import okhttp3.Call; import okhttp3.Callback; import okhttp3.Headers; @@ -61,6 +62,7 @@ import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -70,7 +72,8 @@ public class GitHubClientTest { private GitHubClient github; private OkHttpClient client; - private Tracer tracer = mock(Tracer.class); + 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()); @@ -80,6 +83,7 @@ private static String getFixture(String resource) throws IOException { public void setUp() { client = mock(OkHttpClient.class); github = GitHubClient.create(client, URI.create("http://bogus"), "token"); + when(tracer.span(any())).thenReturn(mockSpan); } @Test @@ -89,7 +93,9 @@ public void withScopedInstallationIdShouldFailWhenMissingPrivateKey() { @Test public void testWithScopedInstallationId() throws URISyntaxException { - GitHubClient org = GitHubClient.create(new URI("http://apa.bepa.cepa"), "some_key_content".getBytes(), null, null); + 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()); @@ -123,15 +129,23 @@ public void testSearchIssue() throws Throwable { .build(); when(client.newCall(any())).thenReturn(call); + when(tracer.createTracedClient(client)) + .thenReturn( + new Call.Factory() { + @NotNull + @Override + public Call newCall(@NotNull final Request request) { + return 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()); + verify(tracer, times(1)).span(any(Request.class)); - Exception exception = assertThrows(ExecutionException.class, - maybeSucceeded::get); + Exception exception = assertThrows(ExecutionException.class, maybeSucceeded::get); Assertions.assertEquals(ReadOnlyRepositoryException.class, exception.getCause().getClass()); } @@ -141,18 +155,17 @@ public void testRequestNotOkException() throws Throwable { 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(); + 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"); @@ -196,8 +209,8 @@ public void testPutConvertsToClass() throws Throwable { .request(new Request.Builder().url("http://localhost/").build()) .build(); - CompletableFuture future = github.put("collaborators/", "", - RepositoryInvitation.class); + CompletableFuture future = + github.put("collaborators/", "", RepositoryInvitation.class); callbackCapture.getValue().onResponse(call, response); RepositoryInvitation invitation = future.get(); @@ -213,15 +226,17 @@ public void testGetCheckSuites() throws Throwable { 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(); + 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"); @@ -232,7 +247,6 @@ public void testGetCheckSuites() throws Throwable { assertThat(result.totalCount(), is(1)); assertThat(result.checkSuites().get(0).app().get().slug().get(), is("octoapp")); - } @Test @@ -241,20 +255,35 @@ public void testGetWorkflow() throws Throwable { 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("../workflows/workflows-get-workflow-response.json"))) - .message("") - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://localhost/").build()) - .build(); + final Response response = + new okhttp3.Response.Builder() + .code(200) + .body( + ResponseBody.create( + MediaType.get("application/json"), + getFixture("../workflows/workflows-get-workflow-response.json"))) + .message("") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://localhost/").build()) + .build(); + + when(tracer.createTracedClient(any(OkHttpClient.class))) + .thenReturn( + new Call.Factory() { + @NotNull + @Override + public Call newCall(@NotNull final Request request) { + return call; + } + }); when(client.newCall(any())).thenReturn(call); - WorkflowsClient client = github.withTracer(tracer).createRepositoryClient("testorg", "testrepo") - .createActionsClient().createWorkflowsClient(); + WorkflowsClient client = + github + .withTracer(tracer) + .createRepositoryClient("testorg", "testrepo") + .createActionsClient() + .createWorkflowsClient(); CompletableFuture future = client.getWorkflow(161335); callbackCapture.getValue().onResponse(call, response); @@ -274,7 +303,8 @@ void asAppScopedClientGetsUserClientIfOrgClientNotFound() { var appClientMock = mock(GithubAppClient.class); when(orgClientMock.createGithubAppClient()).thenReturn(appClientMock); - when(appClientMock.getInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); + when(appClientMock.getInstallation()) + .thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); var userClientMock = mock(UserClient.class); when(githubSpy.createUserClient("owner")).thenReturn(userClientMock); @@ -303,7 +333,8 @@ void asAppScopedClientReturnsEmptyIfNoInstallation() { var appClientMock = mock(GithubAppClient.class); when(orgClientMock.createGithubAppClient()).thenReturn(appClientMock); - when(appClientMock.getInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); + when(appClientMock.getInstallation()) + .thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); var userClientMock = mock(UserClient.class); when(githubSpy.createUserClient("owner")).thenReturn(userClientMock); @@ -312,7 +343,8 @@ void asAppScopedClientReturnsEmptyIfNoInstallation() { when(userClientMock.createGithubAppClient()).thenReturn(appClientMock2); var installationMock = mock(Installation.class); - when(appClientMock2.getUserInstallation()).thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); + when(appClientMock2.getUserInstallation()) + .thenReturn(failedFuture(new RequestNotOkException("", "", 404, "", new HashMap<>()))); when(installationMock.id()).thenReturn(1); var maybeScopedClient = githubSpy.asAppScopedClient("owner").toCompletableFuture().join();