mirror of
https://github.com/jlengrand/helidon.git
synced 2026-03-10 08:21:17 +00:00
Example for FT with webserver.
Signed-off-by: Tomas Langer <tomas.langer@oracle.com>
This commit is contained in:
committed by
Santiago Pericasgeertsen
parent
e49ca4b394
commit
753f4043bb
85
examples/webserver/fault-tolerance/pom.xml
Normal file
85
examples/webserver/fault-tolerance/pom.xml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2020 Oracle and/or its affiliates.
|
||||
~
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>io.helidon.applications</groupId>
|
||||
<artifactId>helidon-se</artifactId>
|
||||
<version>2.0.1-SNAPSHOT</version>
|
||||
<relativePath>../../../applications/se/pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<groupId>io.helidon.examples.webserver</groupId>
|
||||
<artifactId>helidon-examples-webserver-fault-tolerance</artifactId>
|
||||
<name>Helidon WebServer Examples FT</name>
|
||||
|
||||
<description>
|
||||
Application demonstrates Fault tolerance used in webserver.
|
||||
</description>
|
||||
|
||||
<properties>
|
||||
<mainClass>io.helidon.webserver.examples.faulttolerance.Main</mainClass>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.helidon.webserver</groupId>
|
||||
<artifactId>helidon-webserver</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.helidon.fault-tolerance</groupId>
|
||||
<artifactId>helidon-fault-tolerance</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hamcrest</groupId>
|
||||
<artifactId>hamcrest-all</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.helidon.webserver</groupId>
|
||||
<artifactId>helidon-webserver-test-support</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.helidon.webclient</groupId>
|
||||
<artifactId>helidon-webclient</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-libs</id>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (c) 2020 Oracle and/or its affiliates.
|
||||
*
|
||||
* 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 io.helidon.webserver.examples.faulttolerance;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.helidon.common.reactive.Single;
|
||||
import io.helidon.faulttolerance.Async;
|
||||
import io.helidon.faulttolerance.Bulkhead;
|
||||
import io.helidon.faulttolerance.CircuitBreaker;
|
||||
import io.helidon.faulttolerance.Fallback;
|
||||
import io.helidon.faulttolerance.Retry;
|
||||
import io.helidon.faulttolerance.Timeout;
|
||||
import io.helidon.webserver.Routing;
|
||||
import io.helidon.webserver.ServerRequest;
|
||||
import io.helidon.webserver.ServerResponse;
|
||||
import io.helidon.webserver.Service;
|
||||
|
||||
/**
|
||||
* Simple service to demonstrate fault tolerance.
|
||||
*/
|
||||
public class FtService implements Service {
|
||||
|
||||
private final Async async;
|
||||
private final Bulkhead bulkhead;
|
||||
private final CircuitBreaker breaker;
|
||||
private final Fallback<String> fallback;
|
||||
private final Retry retry;
|
||||
private final Timeout timeout;
|
||||
|
||||
FtService() {
|
||||
this.async = Async.create();
|
||||
this.bulkhead = Bulkhead.builder()
|
||||
.queueLength(1)
|
||||
.limit(1)
|
||||
.name("helidon-example-bulkhead")
|
||||
.build();
|
||||
this.breaker = CircuitBreaker.builder()
|
||||
.volume(10)
|
||||
.errorRatio(20)
|
||||
.successThreshold(1)
|
||||
.delay(Duration.ofSeconds(5))
|
||||
.build();
|
||||
this.fallback = Fallback.create(this::fallbackToMethod);
|
||||
this.retry = Retry.builder()
|
||||
.retryPolicy(Retry.DelayingRetryPolicy.noDelay(3))
|
||||
.build();
|
||||
this.timeout = Timeout.create(Duration.ofMillis(100));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(Routing.Rules rules) {
|
||||
rules.get("/async", this::async)
|
||||
.get("/bulkhead/{millis}", this::bulkhead)
|
||||
.get("/circuitBreaker/{success}", this::circuitBreaker)
|
||||
.get("/fallback/{success}", this::fallback)
|
||||
.get("/retry/{count}", this::retry)
|
||||
.get("/timeout/{millis}", this::timeout);
|
||||
}
|
||||
|
||||
private void timeout(ServerRequest request, ServerResponse response) {
|
||||
long sleep = Long.parseLong(request.path().param("millis"));
|
||||
|
||||
timeout.invoke(() -> sleep(sleep))
|
||||
.thenAccept(response::send)
|
||||
.exceptionally(response::send);
|
||||
}
|
||||
|
||||
private void retry(ServerRequest request, ServerResponse response) {
|
||||
int count = Integer.parseInt(request.path().param("count"));
|
||||
|
||||
AtomicInteger call = new AtomicInteger(1);
|
||||
AtomicInteger failures = new AtomicInteger();
|
||||
|
||||
retry.invoke(() -> {
|
||||
int current = call.getAndIncrement();
|
||||
if (current < count) {
|
||||
failures.incrementAndGet();
|
||||
return reactiveFailure();
|
||||
}
|
||||
return Single.just("calls/failures: " + current + "/" + failures.get());
|
||||
}).thenAccept(response::send)
|
||||
.exceptionally(response::send);
|
||||
}
|
||||
|
||||
private void fallback(ServerRequest request, ServerResponse response) {
|
||||
boolean success = "true".equalsIgnoreCase(request.path().param("success"));
|
||||
|
||||
if (success) {
|
||||
fallback.invoke(this::reactiveData).thenAccept(response::send);
|
||||
} else {
|
||||
fallback.invoke(this::reactiveFailure).thenAccept(response::send);
|
||||
}
|
||||
}
|
||||
|
||||
private void circuitBreaker(ServerRequest request, ServerResponse response) {
|
||||
boolean success = "true".equalsIgnoreCase(request.path().param("success"));
|
||||
|
||||
if (success) {
|
||||
breaker.invoke(this::reactiveData)
|
||||
.thenAccept(response::send)
|
||||
.exceptionally(response::send);
|
||||
} else {
|
||||
breaker.invoke(this::reactiveFailure)
|
||||
.thenAccept(response::send)
|
||||
.exceptionally(response::send);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void bulkhead(ServerRequest request, ServerResponse response) {
|
||||
long sleep = Long.parseLong(request.path().param("millis"));
|
||||
|
||||
bulkhead.invoke(() -> sleep(sleep))
|
||||
.thenAccept(response::send)
|
||||
.exceptionally(response::send);
|
||||
}
|
||||
|
||||
private void async(ServerRequest request, ServerResponse response) {
|
||||
async.invoke(this::blockingData).thenApply(response::send);
|
||||
}
|
||||
|
||||
private Single<String> reactiveFailure() {
|
||||
return Single.error(new RuntimeException("reactive failure"));
|
||||
}
|
||||
|
||||
private Single<String> sleep(long sleepMillis) {
|
||||
return async.invoke(() -> {
|
||||
try {
|
||||
Thread.sleep(sleepMillis);
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
return "Slept for " + sleepMillis + " ms";
|
||||
});
|
||||
}
|
||||
|
||||
private Single<String> reactiveData() {
|
||||
return async.invoke(this::blockingData);
|
||||
}
|
||||
|
||||
private String blockingData() {
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
return "blocked for 100 millis";
|
||||
}
|
||||
|
||||
private Single<String> fallbackToMethod(Throwable e) {
|
||||
return Single.just("Failed back because of " + e.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2020 Oracle and/or its affiliates.
|
||||
*
|
||||
* 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 io.helidon.webserver.examples.faulttolerance;
|
||||
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import io.helidon.common.LogConfig;
|
||||
import io.helidon.common.http.Http;
|
||||
import io.helidon.common.reactive.Single;
|
||||
import io.helidon.faulttolerance.BulkheadException;
|
||||
import io.helidon.faulttolerance.CircuitBreakerOpenException;
|
||||
import io.helidon.webserver.Routing;
|
||||
import io.helidon.webserver.WebServer;
|
||||
|
||||
/**
|
||||
* Main class of Fault tolerance example.
|
||||
*/
|
||||
public final class Main {
|
||||
// utility class
|
||||
private Main() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the example.
|
||||
*
|
||||
* @param args start arguments are ignored
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
LogConfig.configureRuntime();
|
||||
|
||||
startServer(8079).thenRun(() -> {});
|
||||
}
|
||||
|
||||
static Single<WebServer> startServer(int port) {
|
||||
return WebServer.builder()
|
||||
.routing(routing())
|
||||
.port(port)
|
||||
.build()
|
||||
.start()
|
||||
.peek(server -> {
|
||||
String url = "http://localhost:" + server.port();
|
||||
System.out.println("Server started on " + url);
|
||||
});
|
||||
}
|
||||
|
||||
private static Routing routing() {
|
||||
return Routing.builder()
|
||||
.register("/ft", new FtService())
|
||||
.error(BulkheadException.class,
|
||||
(req, res, ex) -> res.status(Http.Status.SERVICE_UNAVAILABLE_503).send("bulkhead"))
|
||||
.error(CircuitBreakerOpenException.class,
|
||||
(req, res, ex) -> res.status(Http.Status.SERVICE_UNAVAILABLE_503).send("circuit breaker"))
|
||||
.error(TimeoutException.class,
|
||||
(req, res, ex) -> res.status(Http.Status.REQUEST_TIMEOUT_408).send("timeout"))
|
||||
.error(Throwable.class,
|
||||
(req, res, ex) -> res.status(Http.Status.INTERNAL_SERVER_ERROR_500)
|
||||
.send(ex.getClass().getName() + ": " + ex.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2020 Oracle and/or its affiliates.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Example of Fault Tolerance usage in webserver.
|
||||
*/
|
||||
package io.helidon.webserver.examples.faulttolerance;
|
||||
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
|
||||
handlers=io.helidon.common.HelidonConsoleHandler
|
||||
java.util.logging.SimpleFormatter.format=[%1$tc] %4$s: %2$s - %5$s %6$s%n
|
||||
.level=INFO
|
||||
io.helidon.microprofile.server.level=INFO
|
||||
|
||||
#io.helidon.faulttolerance.level=FINEST
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright (c) 2020 Oracle and/or its affiliates.
|
||||
*
|
||||
* 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 io.helidon.webserver.examples.faulttolerance;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.helidon.common.http.Http;
|
||||
import io.helidon.webclient.WebClient;
|
||||
import io.helidon.webclient.WebClientResponse;
|
||||
import io.helidon.webserver.WebServer;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
|
||||
class MainTest {
|
||||
private static WebServer server;
|
||||
private static WebClient client;
|
||||
|
||||
@BeforeAll
|
||||
static void initClass() throws ExecutionException, InterruptedException {
|
||||
server = Main.startServer(0)
|
||||
.await(10, TimeUnit.SECONDS);
|
||||
|
||||
client = WebClient.builder()
|
||||
.baseUri("http://localhost:" + server.port() + "/ft")
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void destroyClass() {
|
||||
server.shutdown()
|
||||
.await(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAsync() {
|
||||
String response = client.get()
|
||||
.path("/async")
|
||||
.request(String.class)
|
||||
.await(5, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("blocked for 100 millis"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBulkhead() throws InterruptedException {
|
||||
// bulkhead is configured for limit of 1 and queue of 1, so third
|
||||
// request should fail
|
||||
client.get()
|
||||
.path("/bulkhead/100000")
|
||||
.request()
|
||||
.thenRun(() -> {});
|
||||
|
||||
client.get()
|
||||
.path("/bulkhead/100000")
|
||||
.request()
|
||||
.thenRun(() -> {});
|
||||
|
||||
// I want to make sure the above is connected
|
||||
Thread.sleep(100);
|
||||
|
||||
WebClientResponse third = client.get()
|
||||
.path("/bulkhead/10000")
|
||||
.request()
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
// registered an error handler in Main
|
||||
assertThat(third.status(), is(Http.Status.SERVICE_UNAVAILABLE_503));
|
||||
assertThat(third.content().as(String.class).await(1, TimeUnit.SECONDS), is("bulkhead"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCircuitBreaker() {
|
||||
String response = client.get()
|
||||
.path("/circuitBreaker/true")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("blocked for 100 millis"));
|
||||
|
||||
// error ratio is 20% within 10 request
|
||||
client.get()
|
||||
.path("/circuitBreaker/false")
|
||||
.request()
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
// should work after first
|
||||
response = client.get()
|
||||
.path("/circuitBreaker/true")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("blocked for 100 millis"));
|
||||
|
||||
// should open after second
|
||||
client.get()
|
||||
.path("/circuitBreaker/false")
|
||||
.request()
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
WebClientResponse clientResponse = client.get()
|
||||
.path("/circuitBreaker/true")
|
||||
.request()
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
response = clientResponse.content().as(String.class).await(1, TimeUnit.SECONDS);
|
||||
|
||||
// registered an error handler in Main
|
||||
assertThat(clientResponse.status(), is(Http.Status.SERVICE_UNAVAILABLE_503));
|
||||
assertThat(response, is("circuit breaker"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFallback() {
|
||||
String response = client.get()
|
||||
.path("/fallback/true")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("blocked for 100 millis"));
|
||||
|
||||
response = client.get()
|
||||
.path("/fallback/false")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("Failed back because of reactive failure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetry() {
|
||||
String response = client.get()
|
||||
.path("/retry/1")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("calls/failures: 1/0"));
|
||||
|
||||
response = client.get()
|
||||
.path("/retry/2")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("calls/failures: 2/1"));
|
||||
|
||||
response = client.get()
|
||||
.path("/retry/3")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("calls/failures: 3/2"));
|
||||
|
||||
WebClientResponse clientResponse = client.get()
|
||||
.path("/retry/4")
|
||||
.request()
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
response = clientResponse.content().as(String.class).await(1, TimeUnit.SECONDS);
|
||||
// no error handler specified
|
||||
assertThat(clientResponse.status(), is(Http.Status.INTERNAL_SERVER_ERROR_500));
|
||||
assertThat(response, is("java.lang.RuntimeException: reactive failure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTimeout() {
|
||||
String response = client.get()
|
||||
.path("/timeout/50")
|
||||
.request(String.class)
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(response, is("Slept for 50 ms"));
|
||||
|
||||
WebClientResponse clientResponse = client.get()
|
||||
.path("/timeout/105")
|
||||
.request()
|
||||
.await(1, TimeUnit.SECONDS);
|
||||
|
||||
response = clientResponse.content().as(String.class).await(1, TimeUnit.SECONDS);
|
||||
// error handler specified in Main
|
||||
assertThat(clientResponse.status(), is(Http.Status.REQUEST_TIMEOUT_408));
|
||||
assertThat(response, is("timeout"));
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,6 @@
|
||||
<module>websocket</module>
|
||||
<module>tls</module>
|
||||
<module>mutual-tls</module>
|
||||
<module>fault-tolerance</module>
|
||||
</modules>
|
||||
</project>
|
||||
|
||||
@@ -73,11 +73,6 @@
|
||||
<artifactId>helidon-webserver-test-support</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.helidon.webclient</groupId>
|
||||
<artifactId>helidon-webclient</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
Reference in New Issue
Block a user