diff --git a/pom.xml b/pom.xml index e93e0ff2..58ca90dd 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 github-client - 0.3.12-SNAPSHOT + 0.4.0-SNAPSHOT com.spotify @@ -86,8 +86,6 @@ 0.31.1 4.11.0 1.42.1 - - ${project.groupId}.githubclient.shade @@ -483,38 +481,6 @@ - - org.apache.maven.plugins - maven-shade-plugin - 3.2.3 - - - package - - shade - - - - - ${project.groupId}:${project.artifactId} - com.squareup.okhttp3 - com.squareup.okio - - - - - okhttp3 - ${shade.id}.okhttp3 - - - okio - ${shade.id}.okio - - - - - - org.apache.maven.plugins maven-javadoc-plugin diff --git a/src/main/java/com/spotify/github/http/BaseHttpResponse.java b/src/main/java/com/spotify/github/http/BaseHttpResponse.java new file mode 100644 index 00000000..0731e041 --- /dev/null +++ b/src/main/java/com/spotify/github/http/BaseHttpResponse.java @@ -0,0 +1,129 @@ +/*- + * -\-\- + * 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.http; + +import java.util.List; +import java.util.Map; + +/** BaseHttpResponse is the base implementation of HttpResponse. */ +public abstract class BaseHttpResponse implements HttpResponse { + private static final int HTTP_OK = 200; + private static final int HTTP_BAD_REQUEST = 400; + + protected final HttpRequest request; + protected final int statusCode; + protected final String statusMessage; + protected final Map> headers; + + public BaseHttpResponse( + final HttpRequest request, + final int statusCode, + final String statusMessage, + final Map> headers) { + this.request = request; + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.headers = headers; + } + + /** + * Returns the request that generated this response. + * + * @return HttpRequest the request that generated this response + */ + @Override + public HttpRequest request() { + return request; + } + + /** + * Returns the HTTP status code of the response. + * + * @return the status code of the response + */ + @Override + public int statusCode() { + return statusCode; + } + + /** + * Returns the HTTP status message of the response. + * + * @return the status message of the response + */ + @Override + public String statusMessage() { + return statusMessage; + } + + /** + * Returns the headers of the response. + * + * @return the headers of the response as a Map of strings + */ + @Override + public Map> headers() { + return this.headers; + } + + /** + * Returns the values of the header with the given name. If the header is not present, this method + * returns null. + * + * @param headerName the name of the header + * @return the values of the header with the given name as a List of strings, or null if the + * header is not present + */ + @Override + public List headers(final String headerName) { + return this.headers.get(headerName); + } + + /** + * Returns the first value of the header with the given name. If the header is not present, this + * method returns null. + * + * @param headerName the name of the header + * @return the first value of the header with the given name, or null if the header is not present + */ + @Override + public String header(final String headerName) { + List headerValues = this.headers(headerName); + if (headerValues == null || headerValues.isEmpty()) { + return null; + } + if (headerValues.size() == 1) { + return headerValues.get(0); + } else { + return String.join(",", headerValues); + } + } + + /** + * Was the request successful? + * + * @return true if the status code is in the range [200, 400) + */ + @Override + public boolean isSuccessful() { + return this.statusCode() >= HTTP_OK && this.statusCode() < HTTP_BAD_REQUEST; + } +} diff --git a/src/main/java/com/spotify/github/http/HttpClient.java b/src/main/java/com/spotify/github/http/HttpClient.java new file mode 100644 index 00000000..bc29e158 --- /dev/null +++ b/src/main/java/com/spotify/github/http/HttpClient.java @@ -0,0 +1,29 @@ +/*- + * -\-\- + * 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.http; + +import com.spotify.github.tracing.Tracer; +import java.util.concurrent.CompletableFuture; + +public interface HttpClient { + CompletableFuture send(HttpRequest request); + void setTracer(Tracer tracer); +} diff --git a/src/main/java/com/spotify/github/http/HttpRequest.java b/src/main/java/com/spotify/github/http/HttpRequest.java new file mode 100644 index 00000000..018797ca --- /dev/null +++ b/src/main/java/com/spotify/github/http/HttpRequest.java @@ -0,0 +1,67 @@ +/*- + * -\-\- + * 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.http; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.spotify.github.GithubStyle; +import java.util.List; +import java.util.Map; +import org.immutables.value.Value; + +import javax.annotation.Nullable; + +@Value.Immutable +@GithubStyle +@JsonSerialize(as = ImmutableHttpRequest.class) +@JsonDeserialize(as = ImmutableHttpRequest.class) +public interface HttpRequest { + @Value.Default + default String method() { + return "GET"; + } + + String url(); + + @Nullable + String body(); + + @Value.Default + default Map> headers() { + return Map.of(); + } + + default List headers(String headerName) { + return headers().get(headerName); + } + + default String header(String headerName) { + List headerValues = this.headers(headerName); + if (headerValues == null || headerValues.isEmpty()) { + return null; + } + if (headerValues.size() == 1) { + return headerValues.get(0); + } else { + return String.join(",", headerValues); + } + } +} diff --git a/src/main/java/com/spotify/github/http/HttpResponse.java b/src/main/java/com/spotify/github/http/HttpResponse.java new file mode 100644 index 00000000..34b9488c --- /dev/null +++ b/src/main/java/com/spotify/github/http/HttpResponse.java @@ -0,0 +1,48 @@ +/*- + * -\-\- + * 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.http; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +public interface HttpResponse { + // Returns the request that was sent to the server + HttpRequest request(); + // Returns the HTTP status code + int statusCode(); + // Returns the HTTP status message + String statusMessage(); + // Returns the response body as an InputStream + InputStream body(); + // Returns the response body as a String + String bodyString(); + // Returns the response headers as a Map + Map> headers(); + // Returns the response headers for a specific header name as a list of Strings + List headers(String headerName); + // Returns the response headers for a specific header name as a single String + String header(String headerName); + // Returns true if the response was successful + boolean isSuccessful(); + // Closes the response + void close(); +} diff --git a/src/main/java/com/spotify/github/http/okhttp/OkHttpHttpClient.java b/src/main/java/com/spotify/github/http/okhttp/OkHttpHttpClient.java new file mode 100644 index 00000000..c5ef9bc4 --- /dev/null +++ b/src/main/java/com/spotify/github/http/okhttp/OkHttpHttpClient.java @@ -0,0 +1,242 @@ +/*- + * -\-\- + * 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.http.okhttp; + +import static okhttp3.MediaType.parse; + +import com.google.common.annotations.VisibleForTesting; +import com.spotify.github.http.HttpClient; +import com.spotify.github.http.HttpRequest; +import com.spotify.github.http.HttpResponse; +import com.spotify.github.http.ImmutableHttpRequest; +import com.spotify.github.tracing.NoopTracer; +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.TraceHelper; +import com.spotify.github.tracing.Tracer; +import com.spotify.github.tracing.opencensus.OpenCensusTracer; +import com.spotify.github.tracing.opentelemetry.OpenTelemetryTracer; +import io.opentelemetry.instrumentation.okhttp.v3_0.OkHttpTelemetry; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import okhttp3.*; +import org.jetbrains.annotations.NotNull; + +/** + * OkHttpHttpClient is the implementation of HttpClient using OkHttp. This also serves as an example + * of how to create a custom HttpClient. This HttpClient is also capable of tracing the requests + * using OpenCensus or OpenTelemetry. + */ +public class OkHttpHttpClient implements HttpClient { + private final OkHttpClient client; + private Tracer tracer; + private Call.Factory callFactory; + + public OkHttpHttpClient(final OkHttpClient client) { + this.client = client; + this.tracer = NoopTracer.INSTANCE; + this.callFactory = createTracedClient(); + } + + public OkHttpHttpClient(final OkHttpClient client, final Tracer tracer) { + this.client = client; + this.tracer = tracer; + this.callFactory = createTracedClient(); + } + + /** + * Send a request and return a future with the response. + * + * @param httpRequest the request to send + * @return a future with the response + */ + @Override + public CompletableFuture send(final HttpRequest httpRequest) { + Request request = buildOkHttpRequest(httpRequest); + CompletableFuture future = new CompletableFuture<>(); + try (Span span = tracer.span(httpRequest)) { + if (this.callFactory == null) { + this.callFactory = createTracedClient(); + } + tracer.attachSpanToFuture(span, future); + try { + this.callFactory + .newCall(request) + .enqueue( + new Callback() { + + @Override + public void onResponse(@NotNull final Call call, @NotNull final Response response) + throws IOException { + future.complete(new OkHttpHttpResponse(httpRequest, response)); + } + + @Override + public void onFailure(@NotNull final Call call, @NotNull final IOException e) { + future.completeExceptionally(e); + } + }); + } catch (Exception e) { + future.completeExceptionally(e); + } + } + return future; + } + + @Override + public void setTracer(final Tracer tracer) { + this.tracer = tracer; + this.callFactory = createTracedClient(); + } + + /** + * Build an OkHttp Request from an HttpRequest. + * + * @param request the HttpRequest + * @return the OkHttp Request + */ + private Request buildOkHttpRequest(final HttpRequest request) { + Request.Builder requestBuilder = new Request.Builder().url(request.url()); + request + .headers() + .forEach( + (key, values) -> { + values.forEach(value -> requestBuilder.addHeader(key, value)); + }); + if (request.method().equals("GET")) { + requestBuilder.get(); + } else { + requestBuilder.method( + request.method(), + RequestBody.create(parse(javax.ws.rs.core.MediaType.APPLICATION_JSON), request.body())); + } + return requestBuilder.build(); + } + + /** + * Build an HttpRequest from an OkHttp Request. + * + * @param request the OkHttp Request + * @return the HttpRequest + */ + private HttpRequest buildHttpRequest(final Request request) { + return ImmutableHttpRequest.builder() + .url(request.url().toString()) + .method(request.method()) + .headers(request.headers().toMultimap()) + .body(Optional.ofNullable(request.body()).map(RequestBody::toString).orElse("")) + .build(); + } + + /** + * Create a traced client based on the tracer. + * + * @return the traced client + */ + private Call.Factory createTracedClient() { + if (this.tracer == null || this.tracer instanceof NoopTracer) { + return createTracedClientNoopTracer(); + } + if (this.tracer instanceof OpenCensusTracer) { + return createTracedClientOpenCensus(); + } + if (this.tracer instanceof OpenTelemetryTracer) { + return createTracedClientOpenTelemetry(); + } + return createTracedClientNoopTracer(); + } + + /** + * Create a traced client with a NoopTracer. + * + * @return the traced client + */ + protected Call.Factory createTracedClientNoopTracer() { + return new Call.Factory() { + @NotNull + @Override + public Call newCall(@NotNull final Request request) { + return client.newCall(request); + } + }; + } + + /** + * Create a traced client with OpenTelemetry. + * + * @return the traced client + */ + @VisibleForTesting + protected Call.Factory createTracedClientOpenTelemetry() { + // OkHttpTelemetry is a helper class that provides a Call.Factory that can be used to trace + return OkHttpTelemetry.builder(((OpenTelemetryTracer) this.tracer).getOpenTelemetry()) + .build() + .newCallFactory(client); + } + + /** + * Create a traced client with OpenCensus. + * + * @return the traced client + */ + protected Call.Factory createTracedClientOpenCensus() { + return new Call.Factory() { + @NotNull + @Override + public Call newCall(@NotNull final Request request) { + CompletableFuture future = new CompletableFuture<>(); + Span span = + OkHttpHttpClient.this + .tracer + .span(buildHttpRequest(request)) + .addTag(TraceHelper.TraceTags.HTTP_URL, request.url().toString()); + OkHttpClient.Builder okBuilder = client.newBuilder(); + // Add a network interceptor to trace the request + 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/http/okhttp/OkHttpHttpResponse.java b/src/main/java/com/spotify/github/http/okhttp/OkHttpHttpResponse.java new file mode 100644 index 00000000..b668ab31 --- /dev/null +++ b/src/main/java/com/spotify/github/http/okhttp/OkHttpHttpResponse.java @@ -0,0 +1,96 @@ +/*- + * -\-\- + * 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.http.okhttp; + +import com.spotify.github.http.BaseHttpResponse; +import com.spotify.github.http.HttpRequest; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.invoke.MethodHandles; +import java.util.Optional; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** OkHttpHttpResponse is the implementation of HttpResponse using OkHttp. */ +public class OkHttpHttpResponse extends BaseHttpResponse { + private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final Response response; + private InputStream body; + private String bodyString; + + public OkHttpHttpResponse(final HttpRequest request, final Response response) { + super(request, response.code(), response.message(), response.headers().toMultimap()); + this.response = response; + } + + @Override + public InputStream body() { + if (body == null) { + body = Optional.ofNullable(response.body()).map(ResponseBody::byteStream).orElse(null); + } + return body; + } + + @Override + public String bodyString() { + if (bodyString == null) { + if (response != null) { + bodyString = responseBodyUnchecked(response); + } + } + return bodyString; + } + + @Override + public void close() { + try { + if (response != null) { + if (response.body() != null) { + response.body().close(); + } + response.close(); + } + } catch (IllegalStateException e) { + log.debug("Failed closing response: {}", e.getMessage()); + } + } + + /** + * Get the response body as a string. + * + * @param response the response + * @return the response body as a string + */ + private static String responseBodyUnchecked(final Response response) { + if (response.body() == null) { + return null; + } + try (ResponseBody body = response.body()) { + return body.string(); + } catch (IOException e) { + throw new UncheckedIOException("Failed getting response body for: " + response, e); + } + } +} diff --git a/src/main/java/com/spotify/github/tracing/BaseTracer.java b/src/main/java/com/spotify/github/tracing/BaseTracer.java index f58037a7..f0ed42a0 100644 --- a/src/main/java/com/spotify/github/tracing/BaseTracer.java +++ b/src/main/java/com/spotify/github/tracing/BaseTracer.java @@ -20,50 +20,54 @@ package com.spotify.github.tracing; -import okhttp3.Request; +import static java.util.Objects.requireNonNull; +import com.spotify.github.http.HttpRequest; 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 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 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 HttpRequest request) { + requireNonNull(request); + return internalSpan(request, null); + } - @Override - public Span span(final Request request, final CompletionStage future) { - return internalSpan(request, future); - } + @Override + public Span span(final HttpRequest request, final CompletionStage future) { + return internalSpan(request, future); + } - protected abstract Span internalSpan( - String path, - String method, - CompletionStage future); + protected abstract Span internalSpan(String path, String method, CompletionStage future); - protected abstract Span internalSpan( - Request request, - CompletionStage future); + protected abstract Span internalSpan(HttpRequest 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(); - }); - } + @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(); + }) + .exceptionally( + t -> { + span.failure(t); + span.close(); + return null; + }); + } } diff --git a/src/main/java/com/spotify/github/tracing/NoopTracer.java b/src/main/java/com/spotify/github/tracing/NoopTracer.java new file mode 100644 index 00000000..b921f897 --- /dev/null +++ b/src/main/java/com/spotify/github/tracing/NoopTracer.java @@ -0,0 +1,82 @@ +/*- + * -\-\- + * 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.http.HttpRequest; +import java.util.concurrent.CompletionStage; + +public class NoopTracer extends BaseTracer { + + public static final NoopTracer INSTANCE = new NoopTracer(); + private static final Span SPAN = + new Span() { + @Override + public Span success() { + return this; + } + + @Override + public Span failure(final Throwable t) { + return this; + } + + @Override + 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() {} + + @Override + protected Span internalSpan(final String path, final String method, final CompletionStage future) { + return SPAN; + } + + @Override + protected Span internalSpan(final HttpRequest request, final CompletionStage future) { + return SPAN; + } +} diff --git a/src/main/java/com/spotify/github/tracing/Tracer.java b/src/main/java/com/spotify/github/tracing/Tracer.java index a7d704c7..afa3499c 100644 --- a/src/main/java/com/spotify/github/tracing/Tracer.java +++ b/src/main/java/com/spotify/github/tracing/Tracer.java @@ -20,31 +20,19 @@ package com.spotify.github.tracing; -import okhttp3.Call; -import okhttp3.OkHttpClient; -import okhttp3.Request; - +import com.spotify.github.http.HttpRequest; import java.util.concurrent.CompletionStage; public interface Tracer { - /** - * Create scoped span. Span will be closed when future completes. - */ - Span span( - String path, String method, CompletionStage future); - - Span span( - String path, String method); + /** Create scoped span. Span will be closed when future completes. */ + Span span(String path, String method, CompletionStage future); - Span span( - Request request); + Span span(String path, String method); - Span span( - Request request, CompletionStage future); + Span span(HttpRequest request); - void attachSpanToFuture(Span span, CompletionStage future); + Span span(HttpRequest request, CompletionStage future); - Call.Factory createTracedClient(OkHttpClient client); + void attachSpanToFuture(Span span, CompletionStage future); } - diff --git a/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java index 51f35825..6e7bb2c3 100644 --- a/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java +++ b/src/main/java/com/spotify/github/tracing/opencensus/OpenCensusTracer.java @@ -23,15 +23,13 @@ import static io.opencensus.trace.Span.Kind.CLIENT; import static java.util.Objects.requireNonNull; +import com.spotify.github.http.HttpRequest; 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 { @@ -61,49 +59,8 @@ protected Span internalSpan( } @Override - protected Span internalSpan(final Request request, final CompletionStage future) { + protected Span internalSpan(final HttpRequest 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); - } - }; + return internalSpan(request.url(), request.method(), future); } } diff --git a/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java index 8e354c18..764ec5c2 100644 --- a/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java +++ b/src/main/java/com/spotify/github/tracing/opentelemetry/OpenTelemetryTracer.java @@ -20,6 +20,9 @@ package com.spotify.github.tracing.opentelemetry; +import static java.util.Objects.requireNonNull; + +import com.spotify.github.http.HttpRequest; import com.spotify.github.tracing.BaseTracer; import com.spotify.github.tracing.Span; import io.opentelemetry.api.GlobalOpenTelemetry; @@ -28,17 +31,10 @@ 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 java.util.concurrent.CompletionStage; 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; @@ -53,6 +49,10 @@ public OpenTelemetryTracer() { this(GlobalOpenTelemetry.get()); } + public OpenTelemetry getOpenTelemetry() { + return openTelemetry; + } + /** * Create a new span for the given path and method. * @@ -97,7 +97,7 @@ protected Span internalSpan( * @return The created span. */ @Override - protected Span internalSpan(final Request request, final CompletionStage future) { + protected Span internalSpan(final HttpRequest request, final CompletionStage future) { requireNonNull(request); // Extract the context from the request headers. Context context = @@ -107,13 +107,14 @@ protected Span internalSpan(final Request request, final CompletionStage futu request, new TextMapGetter<>() { @Override - public Iterable keys(@NotNull final Request carrier) { - return carrier.headers().names(); + public Iterable keys(@NotNull final HttpRequest carrier) { + return carrier.headers().keySet(); } @Nullable @Override - public String get(@Nullable final Request carrier, @NotNull final String key) { + public String get( + @Nullable final HttpRequest carrier, @NotNull final String key) { if (carrier == null) { return null; } @@ -121,11 +122,6 @@ public String get(@Nullable final Request carrier, @NotNull final String 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); + return internalSpan(request.url(), request.method(), future); } } 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 237fd3c0..3a818349 100644 --- a/src/main/java/com/spotify/github/v3/clients/GitHubClient.java +++ b/src/main/java/com/spotify/github/v3/clients/GitHubClient.java @@ -20,10 +20,17 @@ package com.spotify.github.v3.clients; +import static java.util.concurrent.CompletableFuture.completedFuture; + import com.fasterxml.jackson.core.type.TypeReference; import com.spotify.github.async.Async; +import com.spotify.github.http.HttpClient; +import com.spotify.github.http.HttpRequest; +import com.spotify.github.http.HttpResponse; +import com.spotify.github.http.ImmutableHttpRequest; +import com.spotify.github.http.okhttp.OkHttpHttpClient; import com.spotify.github.jackson.Json; -import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.NoopTracer; import com.spotify.github.tracing.Tracer; import com.spotify.github.v3.Team; import com.spotify.github.v3.User; @@ -39,32 +46,23 @@ import com.spotify.github.v3.prs.Review; import com.spotify.github.v3.prs.ReviewRequests; 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; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; - -import static java.util.concurrent.CompletableFuture.completedFuture; -import static okhttp3.MediaType.parse; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import okhttp3.*; +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * GitHub client is a main communication entry point. Provides lower level communication @@ -72,936 +70,1042 @@ */ public class GitHubClient { - private static final int EXPIRY_MARGIN_IN_MINUTES = 5; - private static final int HTTP_NOT_FOUND = 404; + private static final int EXPIRY_MARGIN_IN_MINUTES = 5; + private static final int HTTP_NOT_FOUND = 404; - private Tracer tracer = NoopTracer.INSTANCE; + private Tracer tracer = NoopTracer.INSTANCE; - static final Consumer IGNORE_RESPONSE_CONSUMER = (response) -> { - if (response.body() != null) { - response.body().close(); + static final Consumer IGNORE_RESPONSE_CONSUMER = + (response) -> { + if (response != null) { + response.close(); } - }; - static final TypeReference> LIST_COMMENT_TYPE_REFERENCE = - new TypeReference<>() { - }; - static final TypeReference> LIST_COMMENT_REACTION_TYPE_REFERENCE = - new TypeReference<>() {}; - static final TypeReference> LIST_REPOSITORY = - new TypeReference<>() { - }; - static final TypeReference> LIST_COMMIT_TYPE_REFERENCE = - new TypeReference<>() { - }; - static final TypeReference> LIST_REVIEW_TYPE_REFERENCE = new TypeReference<>() { - }; - static final TypeReference LIST_REVIEW_REQUEST_TYPE_REFERENCE = - new TypeReference<>() { - }; - static final TypeReference> LIST_STATUS_TYPE_REFERENCE = - new TypeReference<>() { - }; - static final TypeReference> LIST_FOLDERCONTENT_TYPE_REFERENCE = - new TypeReference<>() { - }; - static final TypeReference> LIST_PR_TYPE_REFERENCE = - new TypeReference<>() { - }; - static final TypeReference> LIST_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, - * the "installation" of the App must be specified. - * This returns a {@code GitHubClient} that has been scoped to the - * user's/organization's installation of the app, if any. - */ - public CompletionStage> asAppScopedClient(final String owner) { - return Async.exceptionallyCompose(this - .createOrganisationClient(owner) - .createGithubAppClient() - .getInstallation() - .thenApply(Installation::id), e -> { - if (e.getCause() instanceof RequestNotOkException && ((RequestNotOkException) e.getCause()).statusCode() == HTTP_NOT_FOUND) { - return this - .createUserClient(owner) - .createGithubAppClient() - .getUserInstallation() - .thenApply(Installation::id); - } - return CompletableFuture.failedFuture(e); - }) - .thenApply(id -> Optional.of(this.withScopeForInstallationId(id))) - .exceptionally( - e -> { - if (e.getCause() instanceof RequestNotOkException && ((RequestNotOkException) e.getCause()).statusCode() == HTTP_NOT_FOUND) { - return Optional.empty(); - } - throw new RuntimeException(e); - }); - } - - public GitHubClient withTracer(final Tracer tracer) { - this.tracer = tracer; - 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); + }; + 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 HttpClient client; + private Call.Factory callFactory; + private final String token; + + private final byte[] privateKey; + private final Integer appId; + private final Integer installationId; + + private final Map installationTokens; + + private GitHubClient( + final HttpClient client, + final URI baseUrl, + final URI graphqlUrl, + final String accessToken, + final byte[] privateKey, + final Integer appId, + final Integer installationId) { + this.baseUrl = baseUrl; + this.graphqlUrl = Optional.ofNullable(graphqlUrl); + this.token = accessToken; + this.client = client; + this.privateKey = privateKey; + this.appId = appId; + this.installationId = installationId; + this.installationTokens = new ConcurrentHashMap<>(); + } + + private GitHubClient( + final OkHttpClient client, + final URI baseUrl, + final URI graphqlUrl, + final String accessToken, + final byte[] privateKey, + final Integer appId, + final Integer installationId) { + this.baseUrl = baseUrl; + this.graphqlUrl = Optional.ofNullable(graphqlUrl); + this.token = accessToken; + this.client = new OkHttpHttpClient(client); + this.privateKey = privateKey; + this.appId = appId; + this.installationId = installationId; + this.installationTokens = new ConcurrentHashMap<>(); + } + + /** + * Create a github api client with a given base URL and authorization token. + * + * @param baseUrl base URL + * @param token authorization token + * @return github api client + */ + public static GitHubClient create(final URI baseUrl, final String token) { + return new GitHubClient(new OkHttpClient(), baseUrl, null, token, null, null, null); + } + + public static GitHubClient create(final URI baseUrl, final URI graphqlUri, final String token) { + return new GitHubClient(new OkHttpClient(), baseUrl, graphqlUri, token, null, null, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create(final URI baseUrl, final File privateKey, final Integer appId) { + return createOrThrow(new OkHttpClient(), baseUrl, null, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param baseUrl base URL + * @param privateKey the private key as byte array + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final URI baseUrl, final byte[] privateKey, final Integer appId) { + return new GitHubClient(new OkHttpClient(), baseUrl, null, null, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @param installationId the installationID to be authenticated as + * @return github api client + */ + public static GitHubClient create( + final URI baseUrl, final File privateKey, final Integer appId, final Integer installationId) { + return createOrThrow(new OkHttpClient(), baseUrl, null, privateKey, appId, installationId); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param baseUrl base URL + * @param privateKey the private key as byte array + * @param appId the github app ID + * @param installationId the installationID to be authenticated as + * @return github api client + */ + public static GitHubClient create( + final URI baseUrl, + final byte[] privateKey, + final Integer appId, + final Integer installationId) { + return new GitHubClient( + new OkHttpClient(), baseUrl, null, null, privateKey, appId, installationId); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final OkHttpClient httpClient, + final URI baseUrl, + final File privateKey, + final Integer appId) { + return createOrThrow(httpClient, baseUrl, null, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final OkHttpClient httpClient, + final URI baseUrl, + final URI graphqlUrl, + final File privateKey, + final Integer appId) { + return createOrThrow(httpClient, baseUrl, graphqlUrl, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key as byte array + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final OkHttpClient httpClient, + final URI baseUrl, + final byte[] privateKey, + final Integer appId) { + return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final OkHttpClient httpClient, + final URI baseUrl, + final File privateKey, + final Integer appId, + final Integer installationId) { + return createOrThrow(httpClient, baseUrl, null, privateKey, appId, installationId); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key as byte array + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final OkHttpClient httpClient, + final URI baseUrl, + final byte[] privateKey, + final Integer appId, + final Integer installationId) { + return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, installationId); + } + + /** + * Create a github api client with a given base URL and authorization token. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param token authorization token + * @return github api client + */ + public static GitHubClient create( + final OkHttpClient httpClient, final URI baseUrl, final String token) { + return new GitHubClient(httpClient, baseUrl, null, token, null, null, null); + } + + public static GitHubClient create( + final OkHttpClient httpClient, final URI baseUrl, final URI graphqlUrl, final String token) { + return new GitHubClient(httpClient, baseUrl, graphqlUrl, token, null, null, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final HttpClient httpClient, final URI baseUrl, final File privateKey, final Integer appId) { + return createOrThrow(httpClient, baseUrl, null, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final HttpClient httpClient, + final URI baseUrl, + final URI graphqlUrl, + final File privateKey, + final Integer appId) { + return createOrThrow(httpClient, baseUrl, graphqlUrl, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key as byte array + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final HttpClient httpClient, + final URI baseUrl, + final byte[] privateKey, + final Integer appId) { + return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, null); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key PEM file + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final HttpClient httpClient, + final URI baseUrl, + final File privateKey, + final Integer appId, + final Integer installationId) { + return createOrThrow(httpClient, baseUrl, null, privateKey, appId, installationId); + } + + /** + * Create a github api client with a given base URL and a path to a key. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param privateKey the private key as byte array + * @param appId the github app ID + * @return github api client + */ + public static GitHubClient create( + final HttpClient httpClient, + final URI baseUrl, + final byte[] privateKey, + final Integer appId, + final Integer installationId) { + return new GitHubClient(httpClient, baseUrl, null, null, privateKey, appId, installationId); + } + + /** + * Create a github api client with a given base URL and authorization token. + * + * @param httpClient an instance of OkHttpClient + * @param baseUrl base URL + * @param token authorization token + * @return github api client + */ + public static GitHubClient create( + final HttpClient httpClient, final URI baseUrl, final String token) { + return new GitHubClient(httpClient, baseUrl, null, token, null, null, null); + } + + public static GitHubClient create( + final HttpClient httpClient, final URI baseUrl, final URI graphqlUrl, final String token) { + return new GitHubClient(httpClient, baseUrl, graphqlUrl, token, null, null, null); + } + + /** + * Receives a github client and scopes it to a certain installation ID. + * + * @param client the github client with a valid private key + * @param installationId the installation ID to be scoped + * @return github api client + */ + public static GitHubClient scopeForInstallationId( + final GitHubClient client, final int installationId) { + if (client.getPrivateKey().isEmpty()) { + throw new RuntimeException("Installation ID scoped client needs a private key"); } - - /** - * 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); + return new GitHubClient( + client.client, + client.baseUrl, + null, + null, + client.getPrivateKey().get(), + client.appId, + installationId); + } + + public GitHubClient withScopeForInstallationId(final int installationId) { + if (Optional.ofNullable(privateKey).isEmpty()) { + throw new RuntimeException("Installation ID scoped client needs a private key"); } - - /** - * 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)); + return new GitHubClient( + client, baseUrl, graphqlUrl.orElse(null), null, privateKey, appId, installationId); + } + + /** + * This is for clients authenticated as a GitHub App: when performing operations, the + * "installation" of the App must be specified. This returns a {@code GitHubClient} that has been + * scoped to the user's/organization's installation of the app, if any. + */ + public CompletionStage> asAppScopedClient(final String owner) { + return Async.exceptionallyCompose( + this.createOrganisationClient(owner) + .createGithubAppClient() + .getInstallation() + .thenApply(Installation::id), + e -> { + if (e.getCause() instanceof RequestNotOkException + && ((RequestNotOkException) e.getCause()).statusCode() == HTTP_NOT_FOUND) { + return this.createUserClient(owner) + .createGithubAppClient() + .getUserInstallation() + .thenApply(Installation::id); + } + return CompletableFuture.failedFuture(e); + }) + .thenApply(id -> Optional.of(this.withScopeForInstallationId(id))) + .exceptionally( + e -> { + if (e.getCause() instanceof RequestNotOkException + && ((RequestNotOkException) e.getCause()).statusCode() == HTTP_NOT_FOUND) { + return Optional.empty(); + } + throw new RuntimeException(e); + }); + } + + public GitHubClient withTracer(final Tracer tracer) { + this.tracer = tracer; + this.client.setTracer(tracer); + return this; + } + + public Optional getPrivateKey() { + return Optional.ofNullable(privateKey); + } + + public Optional getAccessToken() { + return Optional.ofNullable(token); + } + + /** + * Create a repository API client + * + * @param owner repository owner + * @param repo repository name + * @return repository API client + */ + public RepositoryClient createRepositoryClient(final String owner, final String repo) { + return RepositoryClient.create(this, owner, repo); + } + + /** + * Create a GitData API client + * + * @param owner repository owner + * @param repo repository name + * @return GitData API client + */ + public GitDataClient createGitDataClient(final String owner, final String repo) { + return GitDataClient.create(this, owner, repo); + } + + /** + * Create search API client + * + * @return search API client + */ + public SearchClient createSearchClient() { + return SearchClient.create(this); + } + + /** + * Create a checks API client + * + * @param owner repository owner + * @param repo repository name + * @return checks API client + */ + public ChecksClient createChecksClient(final String owner, final String repo) { + return ChecksClient.create(this, owner, repo); + } + + /** + * Create organisation API client + * + * @return organisation API client + */ + public OrganisationClient createOrganisationClient(final String org) { + return OrganisationClient.create(this, org); + } + + /** + * Create user API client + * + * @return user API client + */ + public UserClient createUserClient(final String owner) { + return UserClient.create(this, owner); + } + + Json json() { + return json; + } + + /** + * Make 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 HttpRequest 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 ImmutableHttpRequest.Builder builder = requestBuilder(path); + final HttpRequest request = toHttpRequestHeaders(builder, extraHeaders).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 HttpRequest request = requestBuilder(path).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), 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 ImmutableHttpRequest.Builder builder = requestBuilder(path); + final HttpRequest request = toHttpRequestHeaders(builder, extraHeaders).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), 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 ImmutableHttpRequest.Builder builder = requestBuilder(path); + final HttpRequest request = toHttpRequestHeaders(builder, extraHeaders).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply( + response -> json().fromJsonUncheckedNotNull(response.bodyString(), 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 HttpRequest request = requestBuilder(path).build(); + log.debug("Making request to {}", request.url().toString()); + return call(request) + .thenApply( + response -> json().fromJsonUncheckedNotNull(response.bodyString(), 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 HttpRequest request = requestBuilder(path).method("POST").body(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 ImmutableHttpRequest.Builder builder = requestBuilder(path).method("POST").body(data); + final HttpRequest request = toHttpRequestHeaders(builder, extraHeaders).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(response.bodyString(), 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(response.bodyString(), clazz)); + } + + /** + * Make a POST request to the graphql endpoint of Github + * + * @param data request body as stringified JSON + * @return response + * @see + * "https://docs.github.com/en/enterprise-server@3.9/graphql/guides/forming-calls-with-graphql#communicating-with-graphql" + */ + public CompletableFuture postGraphql(final String data) { + final HttpRequest request = graphqlRequestBuilder().method("POST").body(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 HttpRequest request = requestBuilder(path).method("PUT").body(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(response.bodyString(), 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 HttpRequest request = requestBuilder(path).method("PATCH").body(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(response.bodyString(), 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 ImmutableHttpRequest.Builder builder = requestBuilder(path).method("PATCH").body(data); + final HttpRequest request = toHttpRequestHeaders(builder, extraHeaders).build(); + log.debug("Making PATCH request to {}", request.url().toString()); + return call(request) + .thenApply(response -> json().fromJsonUncheckedNotNull(response.bodyString(), 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 HttpRequest request = requestBuilder(path).method("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 HttpRequest request = requestBuilder(path).method("DELETE").body(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 ImmutableHttpRequest.Builder toHttpRequestHeaders( + final ImmutableHttpRequest.Builder builder, final Map extraHeaders) { + HttpRequest request = builder.build(); + + extraHeaders.forEach( + (headerKey, headerValue) -> { + if (request.headers().containsKey(headerKey)) { + List headers = new ArrayList<>(request.headers().get(headerKey)); + headers.add(headerValue); + builder.putHeaders(headerKey, headers); + } else { + builder.putHeaders(headerKey, List.of(headerValue)); + } + }); + return builder; + } + + private ImmutableHttpRequest.Builder requestBuilder(final String path) { + + return ImmutableHttpRequest.builder() + .url(urlFor(path)) + .method("GET") + .body("") + .putHeaders(HttpHeaders.ACCEPT, List.of(MediaType.APPLICATION_JSON)) + .putHeaders(HttpHeaders.CONTENT_TYPE, List.of(MediaType.APPLICATION_JSON)) + .putHeaders(HttpHeaders.AUTHORIZATION, List.of(getAuthorizationHeader(path))); + } + + private ImmutableHttpRequest.Builder graphqlRequestBuilder() { + URI url = graphqlUrl.orElseThrow(() -> new IllegalStateException("No graphql url set")); + return ImmutableHttpRequest.builder() + .url(url.toString()) + .putHeaders(HttpHeaders.ACCEPT, List.of(MediaType.APPLICATION_JSON)) + .putHeaders(HttpHeaders.CONTENT_TYPE, List.of(MediaType.APPLICATION_JSON)) + .putHeaders(HttpHeaders.AUTHORIZATION, List.of(getAuthorizationHeader("/graphql"))); + } + + 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"); } - - /** - * 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)); + 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. "); + } - /** - * 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)); - } + private boolean isJwtRequest(final String path) { + return path.startsWith("/app/installation") || path.endsWith("installation"); + } - /** - * 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)); - } + private String getInstallationToken(final String jwtToken, final int installationId) + throws Exception { - /** - * 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); - } + AccessToken installationToken = installationTokens.get(installationId); - /** - * 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); + 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); } - - /** - * 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)); + 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 HttpRequest request = + ImmutableHttpRequest.builder() + .url(url) + .putHeaders("Accept", List.of("application/vnd.github.machine-man-preview+json")) + .putHeaders("Authorization", List.of("Bearer " + jwtToken)) + .method("POST") + .body("") + .build(); + + final HttpResponse response = this.client.send(request).toCompletableFuture().join(); + + if (!response.isSuccessful()) { + throw new Exception( + String.format( + "Got non-2xx status %s when getting an access token from GitHub: %s", + response.statusCode(), response.statusMessage())); } - /** - * 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); + if (response.bodyString() == null) { + throw new Exception( + String.format( + "Got empty response body when getting an access token from GitHub, HTTP status was: %s", + response.statusMessage())); } - - /** - * 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; + final String text = response.bodyString(); + return Json.create().fromJson(text, AccessToken.class); + } + + private CompletableFuture call(final HttpRequest httpRequest) { + return this.client + .send(httpRequest) + .thenCompose(httpResponse -> handleResponse(httpRequest, httpResponse)); + } + + private CompletableFuture handleResponse( + final HttpRequest httpRequest, final HttpResponse httpResponse) { + final CompletableFuture future = new CompletableFuture<>(); + // avoid multiple redirects + final AtomicBoolean redirected = new AtomicBoolean(false); + processPossibleRedirects(httpResponse, redirected) + .handle( + (res, ex) -> { + if (Objects.nonNull(ex)) { + future.completeExceptionally(ex); + } else if (!res.isSuccessful()) { + try { + future.completeExceptionally(mapException(httpRequest, res)); + } catch (final Throwable e) { + future.completeExceptionally(e); + } + } else { + future.complete(res); + } + return res; + }) + .join(); + return future; + } + + private RequestNotOkException mapException( + final HttpRequest httpRequest, final HttpResponse httpResponse) throws IOException { + String bodyString = Optional.ofNullable(httpResponse.bodyString()).orElse(""); + Map> headersMap = httpResponse.headers(); + + if (httpResponse.statusCode() == FORBIDDEN) { + if (bodyString.contains("Repository was archived so is read-only")) { + return new ReadOnlyRepositoryException( + httpRequest.method(), + URI.create(httpRequest.url()).getPath(), + httpResponse.statusCode(), + bodyString, + headersMap); + } } - 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; + return new RequestNotOkException( + httpRequest.method(), + URI.create(httpRequest.url()).getPath(), + httpResponse.statusCode(), + bodyString, + headersMap); + } + + CompletableFuture processPossibleRedirects( + final HttpResponse response, final AtomicBoolean redirected) { + if (response.statusCode() >= PERMANENT_REDIRECT + && response.statusCode() <= TEMPORARY_REDIRECT + && !redirected.get()) { + redirected.set(true); + // redo the same request with a new URL + final String newLocation = response.headers().get("Location").get(0); + final HttpRequest request = + requestBuilder(newLocation) + .url(newLocation) + .method(response.request().method()) + .body(response.request().body()) + .build(); + // Do the new call and complete the original future when the new call completes + return call(request); } - public boolean isGraphqlEnabled() { - return graphqlUrl.isPresent(); + 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); } - - - /* - 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; - }); - } - }); - 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); - } + } + + /** Wrapper to Constructors that expose File object for the privateKey argument */ + private static GitHubClient createOrThrow( + final HttpClient httpClient, + final URI baseUrl, + final URI graphqlUrl, + final File privateKey, + final Integer appId, + final Integer installationId) { + try { + return new GitHubClient( + httpClient, + baseUrl, + graphqlUrl, + null, + FileUtils.readFileToByteArray(privateKey), + appId, + installationId); + } catch (IOException e) { + throw new RuntimeException("There was an error generating JWT token", e); } + } } diff --git a/src/main/java/com/spotify/github/v3/clients/GithubPage.java b/src/main/java/com/spotify/github/v3/clients/GithubPage.java index 6bbd4ad0..ee3f4436 100644 --- a/src/main/java/com/spotify/github/v3/clients/GithubPage.java +++ b/src/main/java/com/spotify/github/v3/clients/GithubPage.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. @@ -20,7 +20,6 @@ package com.spotify.github.v3.clients; -import static com.spotify.github.v3.clients.GitHubClient.responseBodyUnchecked; import static java.util.Arrays.stream; import static java.util.Objects.nonNull; import static java.util.function.Function.identity; @@ -37,8 +36,6 @@ import java.util.NoSuchElementException; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.stream.Stream; -import okhttp3.ResponseBody; /** * Async page implementation for github resources @@ -147,9 +144,7 @@ public Iterator iterator() { .request(path) .thenApply( response -> - github - .json() - .fromJsonUncheckedNotNull(responseBodyUnchecked(response), typeReference)) + github.json().fromJsonUncheckedNotNull(response.bodyString(), typeReference)) .join() .iterator(); } @@ -159,13 +154,12 @@ private CompletableFuture> linkMapAsync() { .request(path) .thenApply( response -> { - Optional.ofNullable(response.body()).ifPresent(ResponseBody::close); - return Optional.ofNullable(response.headers().get("Link")) - .map(linkHeader -> stream(linkHeader.split(","))) - .orElseGet(Stream::empty) - .map(linkString -> Link.from(linkString.split(";"))) - .filter(link -> link.rel().isPresent()) - .collect(toMap(link -> link.rel().get(), identity())); + return Optional.ofNullable(response.header("Link")) + .stream() + .flatMap(linkHeader -> stream(linkHeader.split(","))) + .map(linkString -> Link.from(linkString.split(";"))) + .filter(link -> link.rel().isPresent()) + .collect(toMap(link -> link.rel().get(), identity())); }); } diff --git a/src/main/java/com/spotify/github/v3/clients/IssueClient.java b/src/main/java/com/spotify/github/v3/clients/IssueClient.java index 9d8df594..f84c2c8b 100644 --- a/src/main/java/com/spotify/github/v3/clients/IssueClient.java +++ b/src/main/java/com/spotify/github/v3/clients/IssueClient.java @@ -24,6 +24,7 @@ import com.google.common.collect.ImmutableMap; import com.spotify.github.async.AsyncPage; +import com.spotify.github.http.HttpResponse; import com.spotify.github.v3.comment.Comment; import com.spotify.github.v3.comment.CommentReaction; import com.spotify.github.v3.comment.CommentReactionContent; @@ -31,7 +32,6 @@ import java.lang.invoke.MethodHandles; import java.util.Iterator; import java.util.concurrent.CompletableFuture; -import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -167,7 +167,7 @@ public CompletableFuture createCommentReaction( * @param issueNumber issue number * @param reactionId reaction id */ - public CompletableFuture deleteCommentReaction( + public CompletableFuture deleteCommentReaction( final long issueNumber, final long reactionId) { final String path = String.format(COMMENTS_REACTION_ID_TEMPLATE, owner, repo, issueNumber, reactionId); diff --git a/src/main/java/com/spotify/github/v3/clients/NoopTracer.java b/src/main/java/com/spotify/github/v3/clients/NoopTracer.java deleted file mode 100644 index 60d64271..00000000 --- a/src/main/java/com/spotify/github/v3/clients/NoopTracer.java +++ /dev/null @@ -1,132 +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.v3.clients; - -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(); - private static final Span SPAN = - new Span() { - @Override - public Span success() { - return this; - } - - @Override - public Span failure(final Throwable t) { - return this; - } - - @Override - 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() { - } - - @Override - public Span span( - final String path, - final String method, - final CompletionStage future) { - 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/main/java/com/spotify/github/v3/clients/PullRequestClient.java b/src/main/java/com/spotify/github/v3/clients/PullRequestClient.java index 1053ec78..9cb703cc 100644 --- a/src/main/java/com/spotify/github/v3/clients/PullRequestClient.java +++ b/src/main/java/com/spotify/github/v3/clients/PullRequestClient.java @@ -35,6 +35,8 @@ import com.spotify.github.v3.prs.requests.PullRequestParameters; import com.spotify.github.v3.prs.requests.PullRequestUpdate; import com.spotify.github.v3.repos.CommitItem; + +import java.io.InputStreamReader; import java.io.Reader; import java.lang.invoke.MethodHandles; import java.util.Iterator; @@ -53,7 +55,8 @@ public class PullRequestClient { private static final String PR_NUMBER_TEMPLATE = "/repos/%s/%s/pulls/%s"; private static final String PR_COMMITS_TEMPLATE = "/repos/%s/%s/pulls/%s/commits"; private static final String PR_REVIEWS_TEMPLATE = "/repos/%s/%s/pulls/%s/reviews"; - private static final String PR_REVIEW_REQUESTS_TEMPLATE = "/repos/%s/%s/pulls/%s/requested_reviewers"; + private static final String PR_REVIEW_REQUESTS_TEMPLATE = + "/repos/%s/%s/pulls/%s/requested_reviewers"; private final GitHubClient github; private final String owner; @@ -144,10 +147,10 @@ public CompletableFuture> listCommits(final int number) { * @param number pull request number * @return list of reviews */ - public CompletableFuture> listReviews(final int number) { - final String path = String.format(PR_REVIEWS_TEMPLATE, owner, repo, number); - log.debug("Fetching pull request reviews from " + path); - return github.request(path, LIST_REVIEW_TYPE_REFERENCE); + public CompletableFuture> listReviews(final int number) { + final String path = String.format(PR_REVIEWS_TEMPLATE, owner, repo, number); + log.debug("Fetching pull request reviews from " + path); + return github.request(path, LIST_REVIEW_TYPE_REFERENCE); } /** @@ -171,7 +174,8 @@ public Iterator> listReviews(final int number, final int items * @param properties properties for reviewing the PR, such as sha, body and event * @see "https://developer.github.com/v3/pulls/reviews/#create-a-review-for-a-pull-request" */ - public CompletableFuture createReview(final int number, final ReviewParameters properties) { + public CompletableFuture createReview( + final int number, final ReviewParameters properties) { final String path = String.format(PR_REVIEWS_TEMPLATE, owner, repo, number); final String jsonPayload = github.json().toJsonUnchecked(properties); log.debug("Creating review for PR: " + path); @@ -197,7 +201,8 @@ public CompletableFuture listReviewRequests(final int number) { * @param properties properties for reviewing the PR, such as reviewers and team_reviewers. * @see "https://docs.github.com/en/rest/reference/pulls#request-reviewers-for-a-pull-request" */ - public CompletableFuture requestReview(final int number, final RequestReviewParameters properties) { + public CompletableFuture requestReview( + final int number, final RequestReviewParameters properties) { final String path = String.format(PR_REVIEW_REQUESTS_TEMPLATE, owner, repo, number); final String jsonPayload = github.json().toJsonUnchecked(properties); log.debug("Requesting reviews for PR: " + path); @@ -211,7 +216,8 @@ public CompletableFuture requestReview(final int number, final Requ * @param properties properties for reviewing the PR, such as reviewers and team_reviewers. * @see "https://docs.github.com/en/rest/reference/pulls#request-reviewers-for-a-pull-request" */ - public CompletableFuture removeRequestedReview(final int number, final RequestReviewParameters properties) { + public CompletableFuture removeRequestedReview( + final int number, final RequestReviewParameters properties) { final String path = String.format(PR_REVIEW_REQUESTS_TEMPLATE, owner, repo, number); final String jsonPayload = github.json().toJsonUnchecked(properties); log.debug("Removing requested reviews for PR: " + path); @@ -234,34 +240,36 @@ public CompletableFuture merge(final int number, final MergeParameters pro public CompletableFuture patch(final int number) { final String path = String.format(PR_NUMBER_TEMPLATE, owner, repo, number); - final Map extraHeaders = ImmutableMap.of( - HttpHeaders.ACCEPT, "application/vnd.github.patch" - ); + final Map extraHeaders = + ImmutableMap.of(HttpHeaders.ACCEPT, "application/vnd.github.patch"); log.debug("Fetching pull request patch from " + path); - return github.request(path, extraHeaders) - .thenApply(response -> { - final var body = response.body(); - if (isNull(body)) { - return Reader.nullReader(); - } - return body.charStream(); - }); + return github + .request(path, extraHeaders) + .thenApply( + response -> { + final var body = response.body(); + if (isNull(body)) { + return Reader.nullReader(); + } + return new InputStreamReader(body); + }); } public CompletableFuture diff(final int number) { final String path = String.format(PR_NUMBER_TEMPLATE, owner, repo, number); - final Map extraHeaders = ImmutableMap.of( - HttpHeaders.ACCEPT, "application/vnd.github.diff" - ); + final Map extraHeaders = + ImmutableMap.of(HttpHeaders.ACCEPT, "application/vnd.github.diff"); log.debug("Fetching pull diff from " + path); - return github.request(path, extraHeaders) - .thenApply(response -> { - final var body = response.body(); - if (isNull(body)) { - return Reader.nullReader(); - } - return body.charStream(); - }); + return github + .request(path, extraHeaders) + .thenApply( + response -> { + final var body = response.body(); + if (isNull(body)) { + return Reader.nullReader(); + } + return new InputStreamReader(body); + }); } private CompletableFuture> list(final String parameterPath) { diff --git a/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java b/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java index a4e2f8ef..a80e46e1 100644 --- a/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java +++ b/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java @@ -214,7 +214,7 @@ public Iterator> listAuthenticatedUserRepositories( */ public CompletableFuture isCollaborator(final String user) { final String path = String.format(REPOSITORY_COLLABORATOR, owner, repo, user); - return github.request(path).thenApply(response -> response.code() == NO_CONTENT); + return github.request(path).thenApply(response -> response.statusCode() == NO_CONTENT); } /** @@ -235,7 +235,7 @@ public CompletableFuture> addCollaborator( response -> { // Non-successful statuses result in an RequestNotOkException exception and this code // not called. - if (response.code() == NO_CONTENT) { + if (response.statusCode() == NO_CONTENT) { /* GitHub returns a 204 when: - an existing collaborator is added as a collaborator @@ -248,8 +248,7 @@ public CompletableFuture> addCollaborator( final RepositoryInvitation invitation = github .json() - .fromJsonUnchecked( - GitHubClient.responseBodyUnchecked(response), RepositoryInvitation.class); + .fromJsonUnchecked(response.bodyString(), RepositoryInvitation.class); return Optional.of(invitation); }); } @@ -309,18 +308,7 @@ private CompletableFuture> downloadRepository( final String path, final Optional maybeRef) { final var repoRef = maybeRef.orElse(""); final var repoPath = String.format(path, owner, repo, repoRef); - return github - .request(repoPath) - .thenApply( - response -> { - var body = response.body(); - - if (body == null) { - return Optional.empty(); - } - - return Optional.of(body.byteStream()); - }); + return github.request(repoPath).thenApply(response -> Optional.ofNullable(response.body())); } /** @@ -663,15 +651,12 @@ public CompletableFuture> merge( // Non-successful statuses result in an RequestNotOkException exception and this code // not being called. - if (response.code() == NO_CONTENT) { + if (response.statusCode() == NO_CONTENT) { // Base already contains the head, nothing to merge return Optional.empty(); } final CommitItem commitItem = - github - .json() - .fromJsonUnchecked( - GitHubClient.responseBodyUnchecked(response), CommitItem.class); + github.json().fromJsonUnchecked(response.bodyString(), CommitItem.class); return Optional.of(commitItem); }); } @@ -694,10 +679,7 @@ public CompletableFuture createFork(final String organization) { .thenApply( response -> { final Repository repositoryItem = - github - .json() - .fromJsonUnchecked( - GitHubClient.responseBodyUnchecked(response), Repository.class); + github.json().fromJsonUnchecked(response.bodyString(), Repository.class); return repositoryItem; }); } @@ -714,11 +696,11 @@ private String getContentPath(final String path, final String query) { * * @param request The repository dispatch request. */ - - public CompletableFuture createRepositoryDispatchEvent(final RepositoryDispatch request) { + public CompletableFuture createRepositoryDispatchEvent( + final RepositoryDispatch request) { final String path = String.format(CREATE_REPOSITORY_DISPATCH_EVENT_TEMPLATE, owner, repo); return github .post(path, github.json().toJsonUnchecked(request)) - .thenApply(response -> response.code() == NO_CONTENT); //should always return a 204 + .thenApply(response -> response.statusCode() == NO_CONTENT); // should always return a 204 } } diff --git a/src/main/java/com/spotify/github/v3/clients/UserClient.java b/src/main/java/com/spotify/github/v3/clients/UserClient.java index 0d48e027..6fec500d 100644 --- a/src/main/java/com/spotify/github/v3/clients/UserClient.java +++ b/src/main/java/com/spotify/github/v3/clients/UserClient.java @@ -55,7 +55,7 @@ public CompletableFuture suspendUser( final String path = String.format(SUSPEND_USER_TEMPLATE, username); return github .put(path, github.json().toJsonUnchecked(reason)) - .thenApply(resp -> resp.code() == NO_CONTENT); + .thenApply(resp -> resp.statusCode() == NO_CONTENT); } /** @@ -69,6 +69,6 @@ public CompletableFuture unSuspendUser( final String path = String.format(SUSPEND_USER_TEMPLATE, username); return github .delete(path, github.json().toJsonUnchecked(reason)) - .thenApply(resp -> resp.code() == NO_CONTENT); + .thenApply(resp -> resp.statusCode() == NO_CONTENT); } } diff --git a/src/main/java/com/spotify/github/v3/issues/Issue.java b/src/main/java/com/spotify/github/v3/issues/Issue.java index f4f1d963..2d5c7988 100644 --- a/src/main/java/com/spotify/github/v3/issues/Issue.java +++ b/src/main/java/com/spotify/github/v3/issues/Issue.java @@ -67,7 +67,7 @@ public interface Issue extends CloseTracking { /** Number. */ @Nullable - Integer number(); + Long number(); /** Indicates the state of the issues to return. Can be either open, closed, or all. */ @Nullable diff --git a/src/test/java/com/spotify/github/MockHelper.java b/src/test/java/com/spotify/github/MockHelper.java new file mode 100644 index 00000000..40a7ea16 --- /dev/null +++ b/src/test/java/com/spotify/github/MockHelper.java @@ -0,0 +1,70 @@ +/*- + * -\-\- + * github-client + * -- + * Copyright (C) 2016 - 2020 Spotify AB + * -- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * -/-/- + */ + +package com.spotify.github; + +import com.spotify.github.http.BaseHttpResponse; +import com.spotify.github.http.HttpRequest; +import com.spotify.github.http.HttpResponse; +import com.spotify.github.http.ImmutableHttpRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class MockHelper { + private static final int HTTP_OK = 200; + private static final int HTTP_BAD_REQUEST = 400; + + public static HttpResponse createMockResponse( + final String headerLinksFixture, final String bodyFixture) throws IOException { + int statusCode = 200; + return createMockHttpResponse( + "", statusCode, bodyFixture, Map.of("Link", List.of(headerLinksFixture))); + } + + public static HttpResponse createMockHttpResponse( + final String url, + final int statusCode, + final String body, + final Map> headers) { + HttpRequest httpRequest = ImmutableHttpRequest.builder().url(url).build(); + return new BaseHttpResponse(httpRequest, statusCode, "", headers) { + @Override + public InputStream body() { + if (body != null) { + return new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); + } + return null; + } + + @Override + public String bodyString() { + return Optional.ofNullable(body).orElse(""); + } + + @Override + public void close() {} + }; + } +} diff --git a/src/test/java/com/spotify/github/http/HttpRequestTest.java b/src/test/java/com/spotify/github/http/HttpRequestTest.java new file mode 100644 index 00000000..c0faed77 --- /dev/null +++ b/src/test/java/com/spotify/github/http/HttpRequestTest.java @@ -0,0 +1,75 @@ +/*- + * -\-\- + * 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.http; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.*; + +public class HttpRequestTest { + @Test + void createBareRequest() { + // When + HttpRequest httpRequest = ImmutableHttpRequest.builder().url("https://example.com").build(); + // Then + assertNotNull(httpRequest); + assertEquals("GET", httpRequest.method()); + assertEquals("https://example.com", httpRequest.url()); + assertNull(httpRequest.body()); + assertEquals(0, httpRequest.headers().size()); + assertNull(httpRequest.headers("Accept-Encoding")); + assertNull(httpRequest.header("Accept-Encoding")); + } + + @Test + void createRequest() { + // When + HttpRequest httpRequest = + ImmutableHttpRequest.builder() + .url("https://example.com") + .method("POST") + .body("{\"foo\":\"bar\"}") + .headers( + Map.of( + "Content-Type", + List.of("application/json", "charset=utf-8"), + "Accept", + List.of("application/json"))) + .build(); + // Then + assertNotNull(httpRequest); + assertEquals("POST", httpRequest.method()); + assertEquals("https://example.com", httpRequest.url()); + assertEquals("{\"foo\":\"bar\"}", httpRequest.body()); + assertEquals(2, httpRequest.headers().size()); + assertThat( + httpRequest.headers("Content-Type"), + containsInAnyOrder("application/json", "charset=utf-8")); + assertEquals("application/json,charset=utf-8", httpRequest.header("Content-Type")); + assertThat(httpRequest.headers("Accept"), containsInAnyOrder("application/json")); + assertEquals("application/json", httpRequest.header("Accept")); + } +} diff --git a/src/test/java/com/spotify/github/http/HttpResponseTest.java b/src/test/java/com/spotify/github/http/HttpResponseTest.java new file mode 100644 index 00000000..ea0bc63c --- /dev/null +++ b/src/test/java/com/spotify/github/http/HttpResponseTest.java @@ -0,0 +1,88 @@ +/*- + * -\-\- + * 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.http; + +import static com.spotify.github.MockHelper.createMockHttpResponse; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class HttpResponseTest { + @Test + void createBareResponse() { + // When + HttpResponse httpResponse = createMockHttpResponse("https://example.com", 200, "{}", Map.of()); + // Then + assertNotNull(httpResponse); + assertEquals("GET", httpResponse.request().method()); + assertEquals("https://example.com", httpResponse.request().url()); + assertTrue(httpResponse.isSuccessful()); + assertEquals(0, httpResponse.headers().size()); + assertNull(httpResponse.headers("Accept-Encoding")); + assertNull(httpResponse.header("Accept-Encoding")); + } + + @Test + void createResponse() throws IOException { + // When + HttpResponse httpResponse = + createMockHttpResponse( + "https://example.com", + 200, + "{\"foo\":\"bar\"}", + Map.of( + "Content-Type", + List.of("application/json", "charset=utf-8"), + "Accept", + List.of("application/json"), + "Cache-Control", + List.of("no-cache"), + "Set-Cookie", + List.of("sessionId=abc123", "userId=xyz789"))); + String responseBody = null; + try (InputStream is = httpResponse.body()) { + responseBody = new String(is.readAllBytes()); + } + // Then + assertNotNull(httpResponse); + assertEquals("{\"foo\":\"bar\"}", httpResponse.bodyString()); + assertEquals("{\"foo\":\"bar\"}", responseBody); + assertEquals(4, httpResponse.headers().size()); + assertThat( + httpResponse.headers("Content-Type"), + containsInAnyOrder("application/json", "charset=utf-8")); + assertEquals("application/json,charset=utf-8", httpResponse.header("Content-Type")); + assertThat(httpResponse.headers("Accept"), containsInAnyOrder("application/json")); + assertEquals("application/json", httpResponse.header("Accept")); + assertThat(httpResponse.headers("Cache-Control"), containsInAnyOrder("no-cache")); + assertEquals("no-cache", httpResponse.header("Cache-Control")); + assertThat( + httpResponse.headers("Set-Cookie"), + containsInAnyOrder("sessionId=abc123", "userId=xyz789")); + assertEquals("sessionId=abc123,userId=xyz789", httpResponse.header("Set-Cookie")); + } +} diff --git a/src/test/java/com/spotify/github/http/okhttp/OkHttpHttpClientTest.java b/src/test/java/com/spotify/github/http/okhttp/OkHttpHttpClientTest.java new file mode 100644 index 00000000..90b602c9 --- /dev/null +++ b/src/test/java/com/spotify/github/http/okhttp/OkHttpHttpClientTest.java @@ -0,0 +1,208 @@ +/*- + * -\-\- + * 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.http.okhttp; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.spotify.github.http.HttpRequest; +import com.spotify.github.http.HttpResponse; +import com.spotify.github.http.ImmutableHttpRequest; +import com.spotify.github.tracing.NoopTracer; +import com.spotify.github.tracing.Span; +import com.spotify.github.tracing.TraceHelper; +import com.spotify.github.tracing.Tracer; +import com.spotify.github.tracing.opencensus.OpenCensusTracer; +import com.spotify.github.tracing.opentelemetry.OpenTelemetryTracer; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.stream.Stream; +import okhttp3.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; + +class OkHttpHttpClientTest { + private static final OkHttpClient okHttpClient = mock(OkHttpClient.class); + private static final OkHttpClient.Builder mockOkHttpClientBuilder = + mock(OkHttpClient.Builder.class); + private static final Tracer noopTracer = mock(NoopTracer.class); + private static final Tracer ocTracer = mock(OpenCensusTracer.class); + private static final Tracer otTracer = mock(OpenTelemetryTracer.class); + private static final Span mockSpan = mock(Span.class); + private static final Call.Factory mockCallFactory = mock(Call.Factory.class); + + private static OkHttpHttpClient httpClient; + + static Stream tracers() { + return Stream.of(noopTracer, ocTracer, otTracer); + } + + @BeforeAll + static void setUp() { + httpClient = + new OkHttpHttpClient(okHttpClient, noopTracer) { + @Override + protected Call.Factory createTracedClientOpenTelemetry() { + return mockCallFactory; + } + }; + } + + @BeforeEach + void setUpEach() { + List interceptors = new ArrayList<>(); + when(okHttpClient.newBuilder()).thenReturn(mockOkHttpClientBuilder); + when(mockOkHttpClientBuilder.networkInterceptors()).thenReturn(interceptors); + when(mockOkHttpClientBuilder.build()).thenReturn(okHttpClient); + } + + @AfterEach + void tearDown() { + reset(okHttpClient, noopTracer, ocTracer, otTracer, mockSpan); + } + + @ParameterizedTest + @MethodSource("tracers") + void sendSuccessfully(Tracer tracer) throws IOException { + // Given + final Call call = mock(Call.class); + final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(capture.capture()); + final Response response = + new okhttp3.Response.Builder() + .code(200) + .body(ResponseBody.create(MediaType.get("application/json"), "{\"foo\":\"bar\"}")) + .message("foo") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("https://example.com").build()) + .build(); + + HttpRequest httpRequest = ImmutableHttpRequest.builder().url("https://example.com").build(); + when(okHttpClient.newCall(any())).thenReturn(call); + when(mockCallFactory.newCall(any())).thenReturn(call); + + when(tracer.span(any())).thenReturn(mockSpan); + + // When + httpClient.setTracer(tracer); + CompletableFuture futureResponse = httpClient.send(httpRequest); + capture.getValue().onResponse(call, response); + HttpResponse httpResponse = futureResponse.join(); + + // Then + assertNotNull(httpResponse); + assertEquals("{\"foo\":\"bar\"}", httpResponse.bodyString()); + assertEquals(200, httpResponse.statusCode()); + assertEquals("foo", httpResponse.statusMessage()); + assertTrue(httpResponse.isSuccessful()); + if (tracer instanceof NoopTracer || tracer instanceof OpenTelemetryTracer) { + verify(tracer, times(1)).span(any(HttpRequest.class)); + + } else if (tracer instanceof OpenCensusTracer) { + verify(tracer, times(2)).span(any(HttpRequest.class)); + verify(mockSpan).addTag(TraceHelper.TraceTags.HTTP_URL, "https://example.com/"); + } + verify(mockSpan, times(1)).close(); + } + + @ParameterizedTest + @MethodSource("tracers") + void sendWithException(Tracer tracer) { + // Given + final Call call = mock(Call.class); + final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(capture.capture()); + final IOException exception = new IOException("Network error"); + + HttpRequest httpRequest = ImmutableHttpRequest.builder().url("https://example.com").build(); + when(okHttpClient.newCall(any())).thenReturn(call); + when(mockCallFactory.newCall(any())).thenReturn(call); + when(tracer.span(any())).thenReturn(mockSpan); + + // When + httpClient.setTracer(tracer); + CompletableFuture futureResponse = httpClient.send(httpRequest); + capture.getValue().onFailure(call, exception); + + // Then + assertThrows(CompletionException.class, futureResponse::join); + if (tracer instanceof NoopTracer || tracer instanceof OpenTelemetryTracer) { + verify(tracer, times(1)).span(any(HttpRequest.class)); + + } else if (tracer instanceof OpenCensusTracer) { + verify(tracer, times(2)).span(any(HttpRequest.class)); + verify(mockSpan).addTag(TraceHelper.TraceTags.HTTP_URL, "https://example.com/"); + } + verify(mockSpan, times(1)).close(); + } + + @ParameterizedTest + @MethodSource("tracers") + void sendWithClientError(Tracer tracer) throws IOException { + // Given + final Call call = mock(Call.class); + final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(capture.capture()); + final Response response = + new okhttp3.Response.Builder() + .code(404) + .body( + ResponseBody.create(MediaType.get("application/json"), "{\"error\":\"Not Found\"}")) + .message("Not Found") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("https://example.com").build()) + .build(); + + HttpRequest httpRequest = ImmutableHttpRequest.builder().url("https://example.com").build(); + when(okHttpClient.newCall(any())).thenReturn(call); + when(mockCallFactory.newCall(any())).thenReturn(call); + when(tracer.span(any())).thenReturn(mockSpan); + + // When + httpClient.setTracer(tracer); + CompletableFuture futureResponse = httpClient.send(httpRequest); + capture.getValue().onResponse(call, response); + HttpResponse httpResponse = futureResponse.join(); + + // Then + assertNotNull(httpResponse); + assertEquals("{\"error\":\"Not Found\"}", httpResponse.bodyString()); + assertEquals(404, httpResponse.statusCode()); + assertEquals("Not Found", httpResponse.statusMessage()); + assertFalse(httpResponse.isSuccessful()); + if (tracer instanceof NoopTracer || tracer instanceof OpenTelemetryTracer) { + verify(tracer, times(1)).span(any(HttpRequest.class)); + + } else if (tracer instanceof OpenCensusTracer) { + verify(tracer, times(2)).span(any(HttpRequest.class)); + verify(mockSpan).addTag(TraceHelper.TraceTags.HTTP_URL, "https://example.com/"); + } + verify(mockSpan, times(1)).close(); + } +} diff --git a/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java b/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java index 2d0b6fcf..bd7f2bda 100644 --- a/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java +++ b/src/test/java/com/spotify/github/tracing/OpenCensusTracerTest.java @@ -20,6 +20,7 @@ package com.spotify.github.tracing; +import com.spotify.github.http.HttpRequest; import com.spotify.github.tracing.opencensus.OpenCensusTracer; import io.grpc.Context; import io.opencensus.trace.Span; @@ -119,9 +120,8 @@ public void traceCompletionStageWithRequest(final String requestMethod) throws E 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")); + HttpRequest mockRequest = mock(HttpRequest.class); + when(mockRequest.url()).thenReturn("https://api.github.com/repos/spotify/github-java-client"); when(mockRequest.method()).thenReturn(requestMethod); try (com.spotify.github.tracing.Span span = tracer.span(mockRequest)) { @@ -148,14 +148,6 @@ public void traceCompletionStageWithRequest(final String requestMethod) throws E 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(); diff --git a/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java b/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java index 3cf82360..7db54f9c 100644 --- a/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java +++ b/src/test/java/com/spotify/github/tracing/OpenTelemetryTracerTest.java @@ -20,6 +20,8 @@ package com.spotify.github.tracing; +import com.spotify.github.http.HttpRequest; +import com.spotify.github.http.ImmutableHttpRequest; import com.spotify.github.tracing.opentelemetry.OpenTelemetryTracer; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; @@ -44,6 +46,7 @@ import java.io.IOException; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -123,10 +126,13 @@ public void traceCompletionStageWithRequest(final String requestMethod) throws E 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); + HttpRequest mockRequest = + ImmutableHttpRequest.builder() + .url("https://api.github.com/repos/spotify/github-java-client") + .method(requestMethod) + .body("") + .headers(Map.of()) + .build(); try (com.spotify.github.tracing.Span span = tracer.span(mockRequest)) { tracer.attachSpanToFuture(span, future); @@ -152,24 +158,6 @@ public void traceCompletionStageWithRequest(final String requestMethod) throws E 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); 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 5a9edc45..8725958d 100644 --- a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java @@ -33,6 +33,7 @@ import static org.mockito.Mockito.*; import com.google.common.io.Resources; +import com.spotify.github.http.HttpRequest; import com.spotify.github.tracing.Span; import com.spotify.github.tracing.Tracer; import com.spotify.github.v3.checks.CheckSuiteResponseList; @@ -129,21 +130,12 @@ 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(any(Request.class)); + verify(tracer, times(1)).span(any(HttpRequest.class)); Exception exception = assertThrows(ExecutionException.class, maybeSucceeded::get); Assertions.assertEquals(ReadOnlyRepositoryException.class, exception.getCause().getClass()); @@ -267,16 +259,6 @@ public void testGetWorkflow() throws Throwable { .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 diff --git a/src/test/java/com/spotify/github/v3/clients/IssueClientTest.java b/src/test/java/com/spotify/github/v3/clients/IssueClientTest.java index b7b42d19..bdbf2383 100644 --- a/src/test/java/com/spotify/github/v3/clients/IssueClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/IssueClientTest.java @@ -23,7 +23,7 @@ import static com.google.common.io.Resources.getResource; import static com.spotify.github.FixtureHelper.loadFixture; import static com.spotify.github.v3.clients.IssueClient.*; -import static com.spotify.github.v3.clients.MockHelper.createMockResponse; +import static com.spotify.github.MockHelper.createMockResponse; import static java.lang.String.format; import static java.nio.charset.Charset.defaultCharset; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -43,6 +43,7 @@ import com.google.common.io.Resources; import com.spotify.github.async.Async; import com.spotify.github.async.AsyncPage; +import com.spotify.github.http.HttpResponse; import com.spotify.github.jackson.Json; import com.spotify.github.v3.ImmutableUser; import com.spotify.github.v3.comment.Comment; @@ -56,7 +57,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import okhttp3.Response; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -83,13 +84,13 @@ public void testCommentPaginationSpliterator() throws IOException { "; rel=\"next\", ; rel=\"last\""; final String firstPageBody = Resources.toString(getResource(this.getClass(), "comments_page1.json"), defaultCharset()); - final Response firstPageResponse = createMockResponse(firstPageLink, firstPageBody); + final HttpResponse firstPageResponse = createMockResponse(firstPageLink, firstPageBody); final String lastPageLink = "; rel=\"first\", ; rel=\"prev\""; final String lastPageBody = Resources.toString(getResource(this.getClass(), "comments_page2.json"), defaultCharset()); - final Response lastPageResponse = createMockResponse(lastPageLink, lastPageBody); + final HttpResponse lastPageResponse = createMockResponse(lastPageLink, lastPageBody); when(github.request(format(COMMENTS_URI_NUMBER_TEMPLATE, "someowner", "somerepo", "123"))) .thenReturn(completedFuture(firstPageResponse)); @@ -112,13 +113,13 @@ public void testCommentPaginationForeach() throws IOException { "; rel=\"next\", ; rel=\"last\""; final String firstPageBody = Resources.toString(getResource(this.getClass(), "comments_page1.json"), defaultCharset()); - final Response firstPageResponse = createMockResponse(firstPageLink, firstPageBody); + final HttpResponse firstPageResponse = createMockResponse(firstPageLink, firstPageBody); final String lastPageLink = "; rel=\"first\", ; rel=\"prev\""; final String lastPageBody = Resources.toString(getResource(this.getClass(), "comments_page2.json"), defaultCharset()); - final Response lastPageResponse = createMockResponse(lastPageLink, lastPageBody); + final HttpResponse lastPageResponse = createMockResponse(lastPageLink, lastPageBody); when(github.request(format(COMMENTS_URI_NUMBER_TEMPLATE, "someowner", "somerepo", "123"))) .thenReturn(completedFuture(firstPageResponse)); @@ -142,7 +143,7 @@ public void testCommentPaginationForeach() throws IOException { @Test public void testCommentCreated() throws IOException { final String fixture = loadFixture("clients/comment_created.json"); - final Response response = createMockResponse("", fixture); + final HttpResponse response = createMockResponse("", fixture); final String path = format(COMMENTS_URI_NUMBER_TEMPLATE, "someowner", "somerepo", 10); when(github.post(anyString(), anyString(), eq(Comment.class))).thenCallRealMethod(); when(github.post(eq(path), anyString())).thenReturn(completedFuture(response)); @@ -154,7 +155,7 @@ public void testCommentCreated() throws IOException { @Test public void testCommentCreatedWithLargeId() throws IOException { final String fixture = loadFixture("clients/comment_created_long_id.json"); - final Response response = createMockResponse("", fixture); + final HttpResponse response = createMockResponse("", fixture); final String path = format(COMMENTS_URI_NUMBER_TEMPLATE, "someowner", "somerepo", 10); when(github.post(anyString(), anyString(), eq(Comment.class))).thenCallRealMethod(); when(github.post(eq(path), anyString())).thenReturn(completedFuture(response)); @@ -210,13 +211,13 @@ public void testDeleteIssueCommentReaction() { long reactionId = 385825; final String path = format(COMMENTS_REACTION_ID_TEMPLATE, "someowner", "somerepo", issueNumber, reactionId); - Response mockResponse = mock(Response.class); - when(mockResponse.code()).thenReturn(204); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(204); when(github.delete(eq(path))).thenReturn(completedFuture(mockResponse)); final var response = issueClient.deleteCommentReaction(issueNumber, reactionId).join(); - assertThat(response.code(), is(204)); + assertThat(response.statusCode(), is(204)); assertThat(response, is(mockResponse)); verify(github, times(1)).delete(eq(path)); } @@ -239,7 +240,7 @@ public void testListIssueCommentReaction() throws IOException { "; rel=\"last\"", commentId); final String firstPageBody = github.json().toJsonUnchecked(listResponse.join().toArray()); - final Response firstPageResponse = createMockResponse(firstPageLink, firstPageBody); + final HttpResponse firstPageResponse = createMockResponse(firstPageLink, firstPageBody); when(github.request(eq(path))).thenReturn(completedFuture(firstPageResponse)); final List listCommentReactions = Lists.newArrayList(); diff --git a/src/test/java/com/spotify/github/v3/clients/MockHelper.java b/src/test/java/com/spotify/github/v3/clients/MockHelper.java deleted file mode 100644 index 3bb896ca..00000000 --- a/src/test/java/com/spotify/github/v3/clients/MockHelper.java +++ /dev/null @@ -1,45 +0,0 @@ -/*- - * -\-\- - * github-client - * -- - * Copyright (C) 2016 - 2020 Spotify AB - * -- - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * -/-/- - */ - -package com.spotify.github.v3.clients; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import okhttp3.Headers; -import okhttp3.Response; -import okhttp3.ResponseBody; - -public class MockHelper { - public static Response createMockResponse(final String headerLinksFixture, final String bodyFixture) - throws IOException { - final ResponseBody body = mock(ResponseBody.class); - when(body.string()).thenReturn(bodyFixture); - - final Headers headers = mock(Headers.class); - when(headers.get("Link")).thenReturn(headerLinksFixture); - - final Response response = mock(Response.class); - when(response.headers()).thenReturn(headers); - when(response.body()).thenReturn(body); - return response; - } -} \ No newline at end of file diff --git a/src/test/java/com/spotify/github/v3/clients/PullRequestClientTest.java b/src/test/java/com/spotify/github/v3/clients/PullRequestClientTest.java index 0fdbd67f..5ddc0514 100644 --- a/src/test/java/com/spotify/github/v3/clients/PullRequestClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/PullRequestClientTest.java @@ -32,7 +32,6 @@ import com.google.common.io.Resources; import com.spotify.github.v3.exceptions.RequestNotOkException; import com.spotify.github.v3.prs.ImmutableRequestReviewParameters; -import com.spotify.github.v3.prs.MergeMethod; import com.spotify.github.v3.prs.PullRequest; import com.spotify.github.v3.prs.ReviewRequests; import com.spotify.github.v3.prs.requests.ImmutablePullRequestCreate; @@ -44,9 +43,7 @@ import java.net.URI; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; - import okhttp3.*; -import okio.Buffer; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java b/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java index f6c0ee37..7a621ca2 100644 --- a/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java @@ -29,7 +29,8 @@ import static com.spotify.github.v3.clients.GitHubClient.LIST_PR_TYPE_REFERENCE; import static com.spotify.github.v3.clients.GitHubClient.LIST_REPOSITORY; import static com.spotify.github.v3.clients.GitHubClient.LIST_REPOSITORY_INVITATION; -import static com.spotify.github.v3.clients.MockHelper.createMockResponse; +import static com.spotify.github.MockHelper.createMockHttpResponse; +import static com.spotify.github.MockHelper.createMockResponse; import static com.spotify.github.v3.clients.RepositoryClient.STATUS_URI_TEMPLATE; import static java.lang.String.format; import static java.nio.charset.Charset.defaultCharset; @@ -49,6 +50,7 @@ import com.google.common.io.Resources; import com.spotify.github.async.Async; import com.spotify.github.async.AsyncPage; +import com.spotify.github.http.HttpResponse; import com.spotify.github.jackson.Json; import com.spotify.github.v3.comment.Comment; import com.spotify.github.v3.prs.PullRequestItem; @@ -71,15 +73,11 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; -import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -122,7 +120,10 @@ public void getRepository() throws Exception { public void updateRepository() throws Exception { final CompletableFuture fixture = completedFuture(json.fromJson(getFixture("repository_get.json"), Repository.class)); - when(github.patch(eq("/repos/someowner/somerepo"), eq("{\"allow_auto_merge\":true}"), eq(Repository.class))) + when(github.patch( + eq("/repos/someowner/somerepo"), + eq("{\"allow_auto_merge\":true}"), + eq(Repository.class))) .thenReturn(fixture); RepositoryUpdate request = ImmutableRepositoryUpdate.builder().allowAutoMerge(Optional.of(true)).build(); @@ -150,7 +151,7 @@ public void listOrganizationRepositories() throws Exception { public void listAuthenticatedUserRepositories() throws Exception { final String pageLink = "; rel=\"first\""; final String pageBody = getFixture("list_of_repos_for_authenticated_user.json"); - final Response pageResponse = createMockResponse(pageLink, pageBody); + final HttpResponse pageResponse = createMockResponse(pageLink, pageBody); when(github.request("/user/repos")).thenReturn(completedFuture(pageResponse)); @@ -167,8 +168,8 @@ public void listAuthenticatedUserRepositories() throws Exception { @Test public void isCollaborator() throws Exception { - final Response response = mock(Response.class); - when(response.code()).thenReturn(204); + final HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(204); when(github.request("/repos/someowner/somerepo/collaborators/user")) .thenReturn(completedFuture(response)); boolean isCollaborator = repoClient.isCollaborator("user").get(); @@ -177,8 +178,8 @@ public void isCollaborator() throws Exception { @Test public void isNotCollaborator() throws Exception { - final Response response = mock(Response.class); - when(response.code()).thenReturn(404); + final HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(404); when(github.request("/repos/someowner/somerepo/collaborators/user")) .thenReturn(completedFuture(response)); boolean isCollaborator = repoClient.isCollaborator("user").get(); @@ -187,7 +188,7 @@ public void isNotCollaborator() throws Exception { @Test public void addCollaborator() throws Exception { - final Response response = createMockResponse("", getFixture("repository_invitation.json")); + final HttpResponse response = createMockResponse("", getFixture("repository_invitation.json")); when(github.put("/repos/someowner/somerepo/collaborators/user", "{\"permission\":\"pull\"}")) .thenReturn(completedFuture(response)); @@ -207,8 +208,8 @@ public void addCollaborator() throws Exception { @Test public void addCollaboratorUserExists() throws Exception { - final Response response = mock(Response.class); - when(response.code()).thenReturn(204); + final HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(204); when(github.put("/repos/someowner/somerepo/collaborators/user", "{\"permission\":\"pull\"}")) .thenReturn(completedFuture(response)); @@ -220,7 +221,7 @@ public void addCollaboratorUserExists() throws Exception { @Test public void removeCollaborator() throws Exception { - CompletableFuture response = completedFuture(mock(Response.class)); + CompletableFuture response = completedFuture(mock(HttpResponse.class)); final ArgumentCaptor capture = ArgumentCaptor.forClass(String.class); when(github.delete(capture.capture())).thenReturn(response); @@ -232,7 +233,7 @@ public void removeCollaborator() throws Exception { @Test public void removeInvite() throws Exception { - CompletableFuture response = completedFuture(mock(Response.class)); + CompletableFuture response = completedFuture(mock(HttpResponse.class)); final ArgumentCaptor capture = ArgumentCaptor.forClass(String.class); when(github.delete(capture.capture())).thenReturn(response); @@ -515,7 +516,7 @@ public void listBranches() throws Exception { void listAllBranches() throws Exception { final String link = "; rel=\"last\""; - final Response response = createMockResponse(link, getFixture("list_branches.json")); + final HttpResponse response = createMockResponse(link, getFixture("list_branches.json")); when(github.request("/repos/someowner/somerepo/branches")) .thenReturn(completedFuture(response)); @@ -561,12 +562,12 @@ public void testStatusesPaginationForeach() throws Exception { "; rel=\"next\", ; rel=\"last\""; final String firstPageBody = loadFixture("clients/statuses_page1.json"); - final Response firstPageResponse = createMockResponse(firstPageLink, firstPageBody); + final HttpResponse firstPageResponse = createMockResponse(firstPageLink, firstPageBody); final String lastPageLink = "; rel=\"first\", ; rel=\"prev\""; final String lastPageBody = loadFixture("clients/statuses_page2.json"); - final Response lastPageResponse = createMockResponse(lastPageLink, lastPageBody); + final HttpResponse lastPageResponse = createMockResponse(lastPageLink, lastPageBody); when(github.urlFor("")).thenReturn("https://github.com/api/v3"); @@ -597,17 +598,13 @@ public void testStatusesPaginationForeach() throws Exception { @Test public void merge() throws IOException { - CompletableFuture okResponse = + CompletableFuture okResponse = completedFuture( - new Response.Builder() - .request(new Request.Builder().url("http://example.com/whatever").build()) - .protocol(Protocol.HTTP_1_1) - .message("") - .code(201) - .body( - ResponseBody.create( - MediaType.get("application/json"), getFixture("merge_commit_item.json"))) - .build()); + createMockHttpResponse( + "http://example.com/whatever", + 201, + getFixture("merge_commit_item.json"), + Map.of())); final String expectedRequestBody = json.toJsonUnchecked( ImmutableMap.of( @@ -624,17 +621,10 @@ public void merge() throws IOException { @Test public void createFork() throws IOException { - CompletableFuture okResponse = + CompletableFuture okResponse = completedFuture( - new Response.Builder() - .request(new Request.Builder().url("http://example.com/whatever").build()) - .protocol(Protocol.HTTP_1_1) - .message("") - .code(202) - .body( - ResponseBody.create( - MediaType.get("application/json"), getFixture("fork_create_item.json"))) - .build()); + createMockHttpResponse( + "http://example.com/whatever", 202, getFixture("fork_create_item.json"), Map.of())); final String expectedRequestBody = json.toJsonUnchecked(ImmutableMap.of()); when(github.post("/repos/someowner/somerepo/forks", expectedRequestBody)) .thenReturn(okResponse); @@ -645,14 +635,8 @@ public void createFork() throws IOException { @Test public void mergeNoop() { - CompletableFuture okResponse = - completedFuture( - new Response.Builder() - .request(new Request.Builder().url("http://example.com/whatever").build()) - .protocol(Protocol.HTTP_1_1) - .message("") - .code(204) // No Content - .build()); + CompletableFuture okResponse = + completedFuture(createMockHttpResponse("http://example.com/whatever", 204, null, Map.of())); when(github.post(any(), any())).thenReturn(okResponse); final Optional maybeCommit = repoClient.merge("basebranch", "headbranch").join(); assertThat(maybeCommit, is(Optional.empty())); @@ -660,18 +644,13 @@ public void mergeNoop() { @Test public void shouldDownloadTarball() throws Exception { - CompletableFuture fixture = + CompletableFuture fixture = completedFuture( - new Response.Builder() - .request(new Request.Builder().url("https://example.com/whatever").build()) - .protocol(Protocol.HTTP_1_1) - .message("") - .code(200) - .body( - ResponseBody.create( - "some bytes".getBytes(StandardCharsets.UTF_8), - MediaType.get("application/gzip"))) - .build()); + createMockHttpResponse( + "http://example.com/whatever", + 200, + "some bytes", + Map.of())); when(github.request("/repos/someowner/somerepo/tarball/")).thenReturn(fixture); try (InputStream response = repoClient.downloadTarball().get().orElseThrow()) { @@ -682,18 +661,13 @@ public void shouldDownloadTarball() throws Exception { @Test public void shouldDownloadZipball() throws Exception { - CompletableFuture fixture = + CompletableFuture fixture = completedFuture( - new Response.Builder() - .request(new Request.Builder().url("https://example.com/whatever").build()) - .protocol(Protocol.HTTP_1_1) - .message("") - .code(200) - .body( - ResponseBody.create( - "some bytes".getBytes(StandardCharsets.UTF_8), - MediaType.get("application/gzip"))) - .build()); + createMockHttpResponse( + "http://example.com/whatever", + 200, + "some bytes", + Map.of())); when(github.request("/repos/someowner/somerepo/zipball/")).thenReturn(fixture); try (InputStream response = repoClient.downloadZipball().get().orElseThrow()) { @@ -704,14 +678,8 @@ public void shouldDownloadZipball() throws Exception { @Test public void shouldReturnEmptyOptionalWhenResponseBodyNotPresent() throws Exception { - CompletableFuture fixture = - completedFuture( - new Response.Builder() - .request(new Request.Builder().url("https://example.com/whatever").build()) - .protocol(Protocol.HTTP_1_1) - .message("") - .code(204) // No Content - .build()); + CompletableFuture fixture = + completedFuture(createMockHttpResponse("http://example.com/whatever", 204, null, Map.of())); when(github.request("/repos/someowner/somerepo/zipball/master")).thenReturn(fixture); Optional response = repoClient.downloadZipball("master").get(); @@ -720,23 +688,27 @@ public void shouldReturnEmptyOptionalWhenResponseBodyNotPresent() throws Excepti @Test public void shouldReturnEmptyResponseWhenRepositoryDispatchEndpointTriggered() throws Exception { - final Response response = mock(Response.class); - when(response.code()).thenReturn(204); + final HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(204); ObjectMapper mapper = new ObjectMapper(); ObjectNode clientPayload = mapper.createObjectNode(); - clientPayload.put("my-custom-true-property","true"); + clientPayload.put("my-custom-true-property", "true"); clientPayload.put("my-custom-false-property", "false"); - RepositoryDispatch repositoryDispatchRequest = ImmutableRepositoryDispatch.builder() - .eventType("my-custom-event") - .clientPayload(clientPayload) - .build(); + RepositoryDispatch repositoryDispatchRequest = + ImmutableRepositoryDispatch.builder() + .eventType("my-custom-event") + .clientPayload(clientPayload) + .build(); - when(github.post("/repos/someowner/somerepo/dispatches", json.toJsonUnchecked(repositoryDispatchRequest))).thenReturn(completedFuture(response)); + when(github.post( + "/repos/someowner/somerepo/dispatches", + json.toJsonUnchecked(repositoryDispatchRequest))) + .thenReturn(completedFuture(response)); - boolean repoDispatchResult = repoClient.createRepositoryDispatchEvent(repositoryDispatchRequest).get(); + boolean repoDispatchResult = + repoClient.createRepositoryDispatchEvent(repositoryDispatchRequest).get(); assertTrue(repoDispatchResult); } - } diff --git a/src/test/java/com/spotify/github/v3/clients/TeamClientTest.java b/src/test/java/com/spotify/github/v3/clients/TeamClientTest.java index c9240eb9..a7195fbc 100644 --- a/src/test/java/com/spotify/github/v3/clients/TeamClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/TeamClientTest.java @@ -24,7 +24,7 @@ import static com.spotify.github.v3.clients.GitHubClient.LIST_PENDING_TEAM_INVITATIONS; import static com.spotify.github.v3.clients.GitHubClient.LIST_TEAMS; import static com.spotify.github.v3.clients.GitHubClient.LIST_TEAM_MEMBERS; -import static com.spotify.github.v3.clients.MockHelper.createMockResponse; +import static com.spotify.github.MockHelper.createMockResponse; import static java.nio.charset.Charset.defaultCharset; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.stream.Collectors.toList; @@ -37,6 +37,7 @@ import com.google.common.io.Resources; import com.spotify.github.async.Async; import com.spotify.github.async.AsyncPage; +import com.spotify.github.http.HttpResponse; import com.spotify.github.jackson.Json; import com.spotify.github.v3.Team; import com.spotify.github.v3.User; @@ -48,7 +49,7 @@ import java.io.IOException; import java.util.List; import java.util.concurrent.CompletableFuture; -import okhttp3.Response; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -97,7 +98,7 @@ public void listTeams() throws Exception { @Test public void deleteTeam() throws Exception { - final CompletableFuture response = completedFuture(mock(Response.class)); + final CompletableFuture response = completedFuture(mock(HttpResponse.class)); final ArgumentCaptor capture = ArgumentCaptor.forClass(String.class); when(github.delete(capture.capture())).thenReturn(response); @@ -167,14 +168,14 @@ public void listTeamMembersPaged() throws Exception { "; rel=\"next\", ; rel=\"last\""; final String firstPageBody = Resources.toString(getResource(this.getClass(), "list_members_page1.json"), defaultCharset()); - final Response firstPageResponse = createMockResponse(firstPageLink, firstPageBody); + final HttpResponse firstPageResponse = createMockResponse(firstPageLink, firstPageBody); final String lastPageLink = "; rel=\"first\", ; rel=\"prev\""; final String lastPageBody = Resources.toString(getResource(this.getClass(), "list_members_page2.json"), defaultCharset()); - final Response lastPageResponse = createMockResponse(lastPageLink, lastPageBody); + final HttpResponse lastPageResponse = createMockResponse(lastPageLink, lastPageBody); when(github.request(endsWith("/orgs/github/teams/1/members?per_page=1"))) .thenReturn(completedFuture(firstPageResponse)); @@ -206,7 +207,7 @@ public void updateMembership() throws Exception { @Test public void deleteMembership() throws Exception { - final CompletableFuture response = completedFuture(mock(Response.class)); + final CompletableFuture response = completedFuture(mock(HttpResponse.class)); final ArgumentCaptor capture = ArgumentCaptor.forClass(String.class); when(github.delete(capture.capture())).thenReturn(response); diff --git a/src/test/java/com/spotify/github/v3/clients/UserClientTest.java b/src/test/java/com/spotify/github/v3/clients/UserClientTest.java index ce8e7259..50fcd3b9 100644 --- a/src/test/java/com/spotify/github/v3/clients/UserClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/UserClientTest.java @@ -31,6 +31,7 @@ import static org.mockito.Mockito.when; import com.google.common.io.Resources; +import com.spotify.github.http.HttpResponse; import com.spotify.github.jackson.Json; import com.spotify.github.v3.checks.Installation; import com.spotify.github.v3.user.requests.ImmutableSuspensionReason; @@ -62,8 +63,8 @@ public void setUp() { @Test public void testSuspendUserSuccess() throws Exception { - Response response = mock(Response.class); - when(response.code()).thenReturn(204); + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(204); when(github.put(eq("/users/username/suspended"), any())).thenReturn(completedFuture(response)); final CompletableFuture result = userClient.suspendUser("username", ImmutableSuspensionReason.builder().reason("That's why").build()); assertTrue(result.get()); @@ -71,8 +72,8 @@ public void testSuspendUserSuccess() throws Exception { @Test public void testSuspendUserFailure() throws Exception { - Response response = mock(Response.class); - when(response.code()).thenReturn(403); + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(403); when(github.put(eq("/users/username/suspended"), any())).thenReturn(completedFuture(response)); final CompletableFuture result = userClient.suspendUser("username", ImmutableSuspensionReason.builder().reason("That's why").build()); assertFalse(result.get()); @@ -80,8 +81,8 @@ public void testSuspendUserFailure() throws Exception { @Test public void testUnSuspendUserSuccess() throws Exception { - Response response = mock(Response.class); - when(response.code()).thenReturn(204); + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(204); when(github.delete(eq("/users/username/suspended"), any())).thenReturn(completedFuture(response)); final CompletableFuture result = userClient.unSuspendUser("username", ImmutableSuspensionReason.builder().reason("That's why").build()); assertTrue(result.get()); @@ -89,8 +90,8 @@ public void testUnSuspendUserSuccess() throws Exception { @Test public void testUnSuspendUserFailure() throws Exception { - Response response = mock(Response.class); - when(response.code()).thenReturn(403); + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(403); when(github.delete(eq("/users/username/suspended"), any())).thenReturn(completedFuture(response)); final CompletableFuture result = userClient.unSuspendUser("username", ImmutableSuspensionReason.builder().reason("That's why").build()); assertFalse(result.get()); diff --git a/src/test/java/com/spotify/github/v3/search/SearchTest.java b/src/test/java/com/spotify/github/v3/search/SearchTest.java index 71a3f92b..b4678875 100644 --- a/src/test/java/com/spotify/github/v3/search/SearchTest.java +++ b/src/test/java/com/spotify/github/v3/search/SearchTest.java @@ -41,7 +41,7 @@ public static final void assertSearchIssues(final SearchIssues search) { assertThat( issues.url(), is(URI.create("https://api.github.com/repos/batterseapower/pinyin-toolkit/issues/132"))); - assertThat(issues.number(), is(132)); + assertThat(issues.number(), is(132L)); assertThat(issues.id(), is(35802L)); assertThat(issues.title(), is("Line Number Indexes Beyond 20 Not Displayed")); } @@ -65,6 +65,6 @@ public void testDeserializationWithLargeIssueId() throws IOException { final Issue issue = search.items().get(0); assertThat(issue.id(), is(2592843837L)); - assertThat(issue.number(), is(5514)); + assertThat(issue.number(), is(5514L)); } }