mirror of https://github.com/hyperledger/besu
Expose metrics to prometheus (#506)
A service like the JSON-RPC service is opened up, only serving /metrics requests in a file format for prometheus. New CLI flags are --metrics-enabled and --metrics-listen, just like the --rpc and --ws variants of the same. --host-whitelist is respected the same as the JSON-RPC endpoint.
parent
ae2e1eb520
commit
2c5f49dfef
@ -0,0 +1,104 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2019 ConsenSys AG. |
||||||
|
* |
||||||
|
* 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 tech.pegasys.pantheon.metrics.prometheus; |
||||||
|
|
||||||
|
import java.util.Collection; |
||||||
|
import java.util.Collections; |
||||||
|
import java.util.Objects; |
||||||
|
|
||||||
|
import com.google.common.collect.Lists; |
||||||
|
|
||||||
|
public class MetricsConfiguration { |
||||||
|
private static final String DEFAULT_METRICS_HOST = "127.0.0.1"; |
||||||
|
public static final int DEFAULT_METRICS_PORT = 9545; |
||||||
|
|
||||||
|
private boolean enabled; |
||||||
|
private int port; |
||||||
|
private String host; |
||||||
|
private Collection<String> hostsWhitelist = Collections.singletonList("localhost"); |
||||||
|
|
||||||
|
public static MetricsConfiguration createDefault() { |
||||||
|
final MetricsConfiguration metricsConfiguration = new MetricsConfiguration(); |
||||||
|
metricsConfiguration.setEnabled(false); |
||||||
|
metricsConfiguration.setPort(DEFAULT_METRICS_PORT); |
||||||
|
metricsConfiguration.setHost(DEFAULT_METRICS_HOST); |
||||||
|
|
||||||
|
return metricsConfiguration; |
||||||
|
} |
||||||
|
|
||||||
|
private MetricsConfiguration() {} |
||||||
|
|
||||||
|
public boolean isEnabled() { |
||||||
|
return enabled; |
||||||
|
} |
||||||
|
|
||||||
|
public void setEnabled(final boolean enabled) { |
||||||
|
this.enabled = enabled; |
||||||
|
} |
||||||
|
|
||||||
|
public int getPort() { |
||||||
|
return port; |
||||||
|
} |
||||||
|
|
||||||
|
public void setPort(final int port) { |
||||||
|
this.port = port; |
||||||
|
} |
||||||
|
|
||||||
|
public String getHost() { |
||||||
|
return host; |
||||||
|
} |
||||||
|
|
||||||
|
public void setHost(final String host) { |
||||||
|
this.host = host; |
||||||
|
} |
||||||
|
|
||||||
|
Collection<String> getHostsWhitelist() { |
||||||
|
return Collections.unmodifiableCollection(this.hostsWhitelist); |
||||||
|
} |
||||||
|
|
||||||
|
public void setHostsWhitelist(final Collection<String> hostsWhitelist) { |
||||||
|
this.hostsWhitelist = hostsWhitelist; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String toString() { |
||||||
|
return "MetricsConfiguration{" |
||||||
|
+ "enabled=" |
||||||
|
+ enabled |
||||||
|
+ ", port=" |
||||||
|
+ port |
||||||
|
+ ", host='" |
||||||
|
+ host |
||||||
|
+ '\'' |
||||||
|
+ ", hostsWhitelist=" |
||||||
|
+ hostsWhitelist |
||||||
|
+ '}'; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean equals(final Object o) { |
||||||
|
if (this == o) return true; |
||||||
|
if (o == null || getClass() != o.getClass()) return false; |
||||||
|
final MetricsConfiguration that = (MetricsConfiguration) o; |
||||||
|
return enabled == that.enabled |
||||||
|
&& port == that.port |
||||||
|
&& Objects.equals(host, that.host) |
||||||
|
&& com.google.common.base.Objects.equal( |
||||||
|
Lists.newArrayList(hostsWhitelist), Lists.newArrayList(that.hostsWhitelist)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public int hashCode() { |
||||||
|
return Objects.hash(enabled, port, host, hostsWhitelist); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,231 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2019 ConsenSys AG. |
||||||
|
* |
||||||
|
* 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 tech.pegasys.pantheon.metrics.prometheus; |
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkArgument; |
||||||
|
import static com.google.common.collect.Streams.stream; |
||||||
|
import static tech.pegasys.pantheon.util.NetworkUtility.urlForSocketAddress; |
||||||
|
|
||||||
|
import tech.pegasys.pantheon.metrics.MetricsSystem; |
||||||
|
import tech.pegasys.pantheon.util.NetworkUtility; |
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.OutputStreamWriter; |
||||||
|
import java.net.InetSocketAddress; |
||||||
|
import java.net.SocketException; |
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.util.Optional; |
||||||
|
import java.util.Set; |
||||||
|
import java.util.TreeSet; |
||||||
|
import java.util.concurrent.CompletableFuture; |
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting; |
||||||
|
import com.google.common.base.Splitter; |
||||||
|
import com.google.common.collect.Iterables; |
||||||
|
import io.netty.handler.codec.http.HttpResponseStatus; |
||||||
|
import io.prometheus.client.exporter.common.TextFormat; |
||||||
|
import io.vertx.core.Handler; |
||||||
|
import io.vertx.core.Vertx; |
||||||
|
import io.vertx.core.http.HttpMethod; |
||||||
|
import io.vertx.core.http.HttpServer; |
||||||
|
import io.vertx.core.http.HttpServerOptions; |
||||||
|
import io.vertx.core.http.HttpServerResponse; |
||||||
|
import io.vertx.ext.web.Router; |
||||||
|
import io.vertx.ext.web.RoutingContext; |
||||||
|
import org.apache.logging.log4j.LogManager; |
||||||
|
import org.apache.logging.log4j.Logger; |
||||||
|
|
||||||
|
public class MetricsHttpService { |
||||||
|
private static final Logger LOG = LogManager.getLogger(); |
||||||
|
|
||||||
|
private static final InetSocketAddress EMPTY_SOCKET_ADDRESS = new InetSocketAddress("0.0.0.0", 0); |
||||||
|
|
||||||
|
private final Vertx vertx; |
||||||
|
private final MetricsConfiguration config; |
||||||
|
private final MetricsSystem metricsSystem; |
||||||
|
|
||||||
|
private HttpServer httpServer; |
||||||
|
|
||||||
|
public MetricsHttpService( |
||||||
|
final Vertx vertx, |
||||||
|
final MetricsConfiguration configuration, |
||||||
|
final MetricsSystem metricsSystem) { |
||||||
|
validateConfig(configuration); |
||||||
|
this.vertx = vertx; |
||||||
|
this.config = configuration; |
||||||
|
this.metricsSystem = metricsSystem; |
||||||
|
} |
||||||
|
|
||||||
|
private void validateConfig(final MetricsConfiguration config) { |
||||||
|
checkArgument( |
||||||
|
config.getPort() == 0 || NetworkUtility.isValidPort(config.getPort()), |
||||||
|
"Invalid port configuration."); |
||||||
|
checkArgument(config.getHost() != null, "Required host is not configured."); |
||||||
|
} |
||||||
|
|
||||||
|
public CompletableFuture<?> start() { |
||||||
|
LOG.info("Starting JsonRPC service on {}:{}", config.getHost(), config.getPort()); |
||||||
|
// Create the HTTP server and a router object.
|
||||||
|
httpServer = |
||||||
|
vertx.createHttpServer( |
||||||
|
new HttpServerOptions().setHost(config.getHost()).setPort(config.getPort())); |
||||||
|
|
||||||
|
// Handle json rpc requests
|
||||||
|
final Router router = Router.router(vertx); |
||||||
|
|
||||||
|
// Verify Host header to avoid rebind attack.
|
||||||
|
router.route().handler(checkWhitelistHostHeader()); |
||||||
|
|
||||||
|
router.route("/").method(HttpMethod.GET).handler(this::handleEmptyRequest); |
||||||
|
router |
||||||
|
.route("/metrics") |
||||||
|
.method(HttpMethod.GET) |
||||||
|
.produces(TextFormat.CONTENT_TYPE_004) |
||||||
|
.handler(this::metricsRequest); |
||||||
|
final CompletableFuture<?> resultFuture = new CompletableFuture<>(); |
||||||
|
httpServer |
||||||
|
.requestHandler(router::accept) |
||||||
|
.listen( |
||||||
|
res -> { |
||||||
|
if (!res.failed()) { |
||||||
|
resultFuture.complete(null); |
||||||
|
LOG.info( |
||||||
|
"Metrics service started and listening on {}:{}", |
||||||
|
config.getHost(), |
||||||
|
httpServer.actualPort()); |
||||||
|
return; |
||||||
|
} |
||||||
|
httpServer = null; |
||||||
|
final Throwable cause = res.cause(); |
||||||
|
if (cause instanceof SocketException) { |
||||||
|
resultFuture.completeExceptionally( |
||||||
|
new RuntimeException( |
||||||
|
String.format( |
||||||
|
"Failed to bind metrics listener to %s:%s: %s", |
||||||
|
config.getHost(), config.getPort(), cause.getMessage()))); |
||||||
|
return; |
||||||
|
} |
||||||
|
resultFuture.completeExceptionally(cause); |
||||||
|
}); |
||||||
|
|
||||||
|
return resultFuture; |
||||||
|
} |
||||||
|
|
||||||
|
private Handler<RoutingContext> checkWhitelistHostHeader() { |
||||||
|
return event -> { |
||||||
|
final Optional<String> hostHeader = getAndValidateHostHeader(event); |
||||||
|
if (config.getHostsWhitelist().contains("*") |
||||||
|
|| (hostHeader.isPresent() && hostIsInWhitelist(hostHeader.get()))) { |
||||||
|
event.next(); |
||||||
|
} else { |
||||||
|
event |
||||||
|
.response() |
||||||
|
.setStatusCode(403) |
||||||
|
.putHeader("Content-Type", "text/plain; charset=utf-8") |
||||||
|
.end("Host not authorized."); |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
private Optional<String> getAndValidateHostHeader(final RoutingContext event) { |
||||||
|
final Iterable<String> splitHostHeader = Splitter.on(':').split(event.request().host()); |
||||||
|
final long hostPieces = stream(splitHostHeader).count(); |
||||||
|
if (hostPieces > 1) { |
||||||
|
// If the host contains a colon, verify the host is correctly formed - host [ ":" port ]
|
||||||
|
if (hostPieces > 2 || !Iterables.get(splitHostHeader, 1).matches("\\d{1,5}+")) { |
||||||
|
return Optional.empty(); |
||||||
|
} |
||||||
|
} |
||||||
|
return Optional.ofNullable(Iterables.get(splitHostHeader, 0)); |
||||||
|
} |
||||||
|
|
||||||
|
private boolean hostIsInWhitelist(final String hostHeader) { |
||||||
|
return config |
||||||
|
.getHostsWhitelist() |
||||||
|
.stream() |
||||||
|
.anyMatch(whitelistEntry -> whitelistEntry.toLowerCase().equals(hostHeader.toLowerCase())); |
||||||
|
} |
||||||
|
|
||||||
|
public CompletableFuture<?> stop() { |
||||||
|
if (httpServer == null) { |
||||||
|
return CompletableFuture.completedFuture(null); |
||||||
|
} |
||||||
|
|
||||||
|
final CompletableFuture<?> resultFuture = new CompletableFuture<>(); |
||||||
|
httpServer.close( |
||||||
|
res -> { |
||||||
|
if (res.failed()) { |
||||||
|
resultFuture.completeExceptionally(res.cause()); |
||||||
|
} else { |
||||||
|
httpServer = null; |
||||||
|
resultFuture.complete(null); |
||||||
|
} |
||||||
|
}); |
||||||
|
return resultFuture; |
||||||
|
} |
||||||
|
|
||||||
|
private void metricsRequest(final RoutingContext routingContext) { |
||||||
|
final Set<String> names = new TreeSet<>(routingContext.queryParam("name[]")); |
||||||
|
final HttpServerResponse response = routingContext.response(); |
||||||
|
vertx.<String>executeBlocking( |
||||||
|
future -> { |
||||||
|
try { |
||||||
|
final ByteArrayOutputStream metrics = new ByteArrayOutputStream(16 * 1024); |
||||||
|
final OutputStreamWriter osw = new OutputStreamWriter(metrics, StandardCharsets.UTF_8); |
||||||
|
TextFormat.write004( |
||||||
|
osw, |
||||||
|
((PrometheusMetricsSystem) (metricsSystem)) |
||||||
|
.getRegistry() |
||||||
|
.filteredMetricFamilySamples(names)); |
||||||
|
osw.flush(); |
||||||
|
osw.close(); |
||||||
|
metrics.flush(); |
||||||
|
metrics.close(); |
||||||
|
future.complete(metrics.toString(StandardCharsets.UTF_8.name())); |
||||||
|
} catch (final IOException ioe) { |
||||||
|
future.fail(ioe); |
||||||
|
} |
||||||
|
}, |
||||||
|
false, |
||||||
|
(res) -> { |
||||||
|
if (res.failed()) { |
||||||
|
response.setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()).end(); |
||||||
|
} else { |
||||||
|
response.setStatusCode(HttpResponseStatus.OK.code()); |
||||||
|
response.putHeader("Content-Type", TextFormat.CONTENT_TYPE_004); |
||||||
|
response.end(res.result()); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public InetSocketAddress socketAddress() { |
||||||
|
if (httpServer == null) { |
||||||
|
return EMPTY_SOCKET_ADDRESS; |
||||||
|
} |
||||||
|
return new InetSocketAddress(config.getHost(), httpServer.actualPort()); |
||||||
|
} |
||||||
|
|
||||||
|
@VisibleForTesting |
||||||
|
public String url() { |
||||||
|
if (httpServer == null) { |
||||||
|
return ""; |
||||||
|
} |
||||||
|
return urlForSocketAddress("http", socketAddress()); |
||||||
|
} |
||||||
|
|
||||||
|
// Facilitate remote health-checks in AWS, inter alia.
|
||||||
|
private void handleEmptyRequest(final RoutingContext routingContext) { |
||||||
|
routingContext.response().setStatusCode(201).end(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,175 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2018 ConsenSys AG. |
||||||
|
* |
||||||
|
* 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 tech.pegasys.pantheon.metrics.prometheus; |
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||||
|
|
||||||
|
import java.net.InetSocketAddress; |
||||||
|
import java.util.Collections; |
||||||
|
import java.util.Properties; |
||||||
|
|
||||||
|
import io.vertx.core.Vertx; |
||||||
|
import okhttp3.OkHttpClient; |
||||||
|
import okhttp3.Request; |
||||||
|
import okhttp3.Response; |
||||||
|
import org.junit.AfterClass; |
||||||
|
import org.junit.BeforeClass; |
||||||
|
import org.junit.ClassRule; |
||||||
|
import org.junit.Test; |
||||||
|
import org.junit.rules.TemporaryFolder; |
||||||
|
|
||||||
|
public class MetricsHttpServiceTest { |
||||||
|
|
||||||
|
@ClassRule public static final TemporaryFolder folder = new TemporaryFolder(); |
||||||
|
|
||||||
|
private static final Vertx vertx = Vertx.vertx(); |
||||||
|
|
||||||
|
private static MetricsHttpService service; |
||||||
|
private static OkHttpClient client; |
||||||
|
private static String baseUrl; |
||||||
|
|
||||||
|
@BeforeClass |
||||||
|
public static void initServerAndClient() { |
||||||
|
service = createMetricsHttpService(); |
||||||
|
service.start().join(); |
||||||
|
|
||||||
|
// Build an OkHttp client.
|
||||||
|
client = new OkHttpClient(); |
||||||
|
baseUrl = service.url(); |
||||||
|
} |
||||||
|
|
||||||
|
private static MetricsHttpService createMetricsHttpService(final MetricsConfiguration config) { |
||||||
|
return new MetricsHttpService(vertx, config, PrometheusMetricsSystem.init()); |
||||||
|
} |
||||||
|
|
||||||
|
private static MetricsHttpService createMetricsHttpService() { |
||||||
|
return new MetricsHttpService(vertx, createMetricsConfig(), PrometheusMetricsSystem.init()); |
||||||
|
} |
||||||
|
|
||||||
|
private static MetricsConfiguration createMetricsConfig() { |
||||||
|
final MetricsConfiguration config = MetricsConfiguration.createDefault(); |
||||||
|
config.setPort(0); |
||||||
|
config.setHostsWhitelist(Collections.singletonList("*")); |
||||||
|
return config; |
||||||
|
} |
||||||
|
|
||||||
|
/** Tears down the HTTP server. */ |
||||||
|
@AfterClass |
||||||
|
public static void shutdownServer() { |
||||||
|
service.stop().join(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void invalidCallToStart() { |
||||||
|
service |
||||||
|
.start() |
||||||
|
.whenComplete( |
||||||
|
(unused, exception) -> assertThat(exception).isInstanceOf(IllegalStateException.class)); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void http404() throws Exception { |
||||||
|
try (final Response resp = client.newCall(buildGetRequest("/foo")).execute()) { |
||||||
|
assertThat(resp.code()).isEqualTo(404); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void handleEmptyRequest() throws Exception { |
||||||
|
try (final Response resp = client.newCall(buildGetRequest("")).execute()) { |
||||||
|
assertThat(resp.code()).isEqualTo(201); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void getSocketAddressWhenActive() { |
||||||
|
final InetSocketAddress socketAddress = service.socketAddress(); |
||||||
|
assertThat("127.0.0.1").isEqualTo(socketAddress.getAddress().getHostAddress()); |
||||||
|
assertThat(socketAddress.getPort() > 0).isTrue(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void getSocketAddressWhenStoppedIsEmpty() { |
||||||
|
final MetricsHttpService service = createMetricsHttpService(); |
||||||
|
|
||||||
|
final InetSocketAddress socketAddress = service.socketAddress(); |
||||||
|
assertThat("0.0.0.0").isEqualTo(socketAddress.getAddress().getHostAddress()); |
||||||
|
assertThat(0).isEqualTo(socketAddress.getPort()); |
||||||
|
assertThat("").isEqualTo(service.url()); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void getSocketAddressWhenBindingToAllInterfaces() { |
||||||
|
final MetricsConfiguration config = createMetricsConfig(); |
||||||
|
config.setHost("0.0.0.0"); |
||||||
|
final MetricsHttpService service = createMetricsHttpService(config); |
||||||
|
service.start().join(); |
||||||
|
|
||||||
|
try { |
||||||
|
final InetSocketAddress socketAddress = service.socketAddress(); |
||||||
|
assertThat("0.0.0.0").isEqualTo(socketAddress.getAddress().getHostAddress()); |
||||||
|
assertThat(socketAddress.getPort() > 0).isTrue(); |
||||||
|
assertThat(!service.url().contains("0.0.0.0")).isTrue(); |
||||||
|
} finally { |
||||||
|
service.stop().join(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void metricsArePresent() throws Exception { |
||||||
|
final Request metricsRequest = new Request.Builder().url(baseUrl + "/metrics").build(); |
||||||
|
try (final Response resp = client.newCall(metricsRequest).execute()) { |
||||||
|
assertThat(resp.code()).isEqualTo(200); |
||||||
|
// Check general format of result, it maps to java.util.Properties
|
||||||
|
final Properties props = new Properties(); |
||||||
|
props.load(resp.body().byteStream()); |
||||||
|
|
||||||
|
// We should have JVM metrics already loaded, verify a simple key.
|
||||||
|
assertThat(props).containsKey("jvm_threads_deadlocked"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void metricsArePresentWhenFiltered() throws Exception { |
||||||
|
final Request metricsRequest = |
||||||
|
new Request.Builder().url(baseUrl + "/metrics?name[]=jvm_threads_deadlocked").build(); |
||||||
|
try (final Response resp = client.newCall(metricsRequest).execute()) { |
||||||
|
assertThat(resp.code()).isEqualTo(200); |
||||||
|
// Check general format of result, it maps to java.util.Properties
|
||||||
|
final Properties props = new Properties(); |
||||||
|
props.load(resp.body().byteStream()); |
||||||
|
|
||||||
|
// We should have JVM metrics already loaded, verify a simple key.
|
||||||
|
assertThat(props).containsKey("jvm_threads_deadlocked"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void metricsAreAbsentWhenFiltered() throws Exception { |
||||||
|
final Request metricsRequest = |
||||||
|
new Request.Builder().url(baseUrl + "/metrics?name[]=does_not_exist").build(); |
||||||
|
try (final Response resp = client.newCall(metricsRequest).execute()) { |
||||||
|
assertThat(resp.code()).isEqualTo(200); |
||||||
|
// Check general format of result, it maps to java.util.Properties
|
||||||
|
final Properties props = new Properties(); |
||||||
|
props.load(resp.body().byteStream()); |
||||||
|
|
||||||
|
// We should have JVM metrics already loaded, verify a simple key.
|
||||||
|
assertThat(props).isEmpty(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private Request buildGetRequest(final String path) { |
||||||
|
return new Request.Builder().get().url(baseUrl + path).build(); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue