diff --git a/mvn-resolver-transport-http3/pom.xml b/mvn-resolver-transport-http3/pom.xml index ee19dff..d79f975 100644 --- a/mvn-resolver-transport-http3/pom.xml +++ b/mvn-resolver-transport-http3/pom.xml @@ -25,7 +25,21 @@ Artipie Maven Artifact Resolver Transport HTTP3 A transport implementation for repositories using HTTP3. - + https://github.com/artipie/maven-resolver-http3-plugin + 2023 + + + MIT + https://github.com/artipie/maven-resolver-http3-plugin/blob/master/LICENSE.txt + + + + GitHub + https://github.com/artipie/maven-resolver-http3-plugin/issues + + + https://github.com/artipie/maven-resolver-http3-plugin + com.artipie.maven.resolver.transport.http3 ${Automatic-Module-Name} @@ -132,6 +146,11 @@ jetty-io ${jettyVersion} + + org.eclipse.jetty + jetty-server + ${jettyVersion} + javax.servlet javax.servlet-api diff --git a/mvn-resolver-transport-http3/src/main/java/com/artipie/aether/transport/http3/HttpTransporter.java b/mvn-resolver-transport-http3/src/main/java/com/artipie/aether/transport/http3/HttpTransporter.java index b78f119..1722f1f 100644 --- a/mvn-resolver-transport-http3/src/main/java/com/artipie/aether/transport/http3/HttpTransporter.java +++ b/mvn-resolver-transport-http3/src/main/java/com/artipie/aether/transport/http3/HttpTransporter.java @@ -38,7 +38,10 @@ import static java.util.Objects.requireNonNull; import java.util.Optional; import java.util.ServiceLoader; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.apache.commons.lang3.exception.UncheckedException; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; import org.eclipse.aether.ConfigurationProperties; @@ -69,12 +72,19 @@ import org.eclipse.jetty.http.PreEncodedHttpField; import org.eclipse.jetty.http3.client.HTTP3Client; import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3; +import org.eclipse.jetty.util.ssl.SslContextFactory; /** * A transporter for HTTP/HTTPS. */ final class HttpTransporter extends AbstractTransporter { + private final static Set CENTRAL = Set.of( + "repo.maven.apache.org", + "oss.sonatype.org", + "packages.atlassian.com" + ); + private final Map checksumExtractors; private final AuthenticationContext repoAuthContext; @@ -83,8 +93,11 @@ final class HttpTransporter extends AbstractTransporter { private final URI baseUri; - private final HttpClient client; + private HttpClient http3Client; + private HttpClient httpClient = null; + private final int connectTimeout; + private final String httpsSecurityMode; private String[] authInfo = null; @@ -123,7 +136,7 @@ final class HttpTransporter extends AbstractTransporter { }; } - String httpsSecurityMode = ConfigUtils.getString( + httpsSecurityMode = ConfigUtils.getString( session, ConfigurationProperties.HTTPS_SECURITY_MODE_DEFAULT, ConfigurationProperties.HTTPS_SECURITY_MODE + "." + repository.getId(), @@ -135,16 +148,43 @@ final class HttpTransporter extends AbstractTransporter { ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(), ConfigurationProperties.CONNECT_TIMEOUT ); + this.chooseClient(); + } - HTTP3Client h3Client = new HTTP3Client(); - HttpClientTransportOverHTTP3 transport = new HttpClientTransportOverHTTP3(h3Client); - this.client = new HttpClient(transport); - this.client.setFollowRedirects(true); - this.client.setConnectTimeout(connectTimeout); - this.client.start(); - h3Client.getClientConnector().getSslContextFactory().setTrustAll( - httpsSecurityMode.equals(ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE) - ); + private HttpClient initOrGetHttpClient() { + if (this.httpClient == null) { + this.httpClient = new HttpClient(); + this.httpClient.setFollowRedirects(true); + this.httpClient.setConnectTimeout(connectTimeout); + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setTrustAll(httpsSecurityMode.equals(ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE)); + httpClient.setSslContextFactory(sslContextFactory); + try { + this.httpClient.start(); + } catch (Exception e) { + throw new UncheckedException(e); + } + } + return this.httpClient; + } + + private HttpClient initOrGetHttp3Client() { + if (this.http3Client == null) { + HTTP3Client h3Client = new HTTP3Client(); + HttpClientTransportOverHTTP3 transport = new HttpClientTransportOverHTTP3(h3Client); + this.http3Client = new HttpClient(transport); + this.http3Client.setFollowRedirects(true); + this.http3Client.setConnectTimeout(connectTimeout); + try { + this.http3Client.start(); + h3Client.getClientConnector().getSslContextFactory().setTrustAll( + httpsSecurityMode.equals(ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE) + ); + } catch (Exception e) { + throw new UncheckedException(e); + } + } + return this.http3Client; } @Override @@ -157,12 +197,13 @@ public int classify(Throwable error) { @Override protected void implPeek(PeekTask task) throws Exception { - this.makeRequest(HttpMethod.HEAD, task, null); + this.makeRequest(HttpMethod.HEAD, task, null, this.chooseClient()); } @Override protected void implGet(GetTask task) throws Exception { - final Pair response = this.makeRequest(HttpMethod.GET, task, null); + final Pair response = + this.makeRequest(HttpMethod.GET, task, null, this.chooseClient()); final boolean resume = false; final File dataFile = task.getDataFile(); long length = Long.parseLong( @@ -202,16 +243,21 @@ protected void implGet(GetTask task) throws Exception { protected void implPut(PutTask task) throws Exception { try (final InputStream stream = task.newInputStream()) { this.makeRequest(HttpMethod.PUT, task, - new InputStreamRequestContent(stream) - ); + new InputStreamRequestContent(stream), this.chooseClient()); } } @Override protected void implClose() { try { - client.stop(); - client.destroy(); + if (this.http3Client != null) { + http3Client.stop(); + http3Client.destroy(); + } + if (this.httpClient != null) { + this.httpClient.stop(); + this.httpClient.destroy(); + } } catch (Exception e) { throw new UncheckedIOException(new IOException(e)); } @@ -220,17 +266,20 @@ protected void implClose() { } private Pair makeRequest( - HttpMethod method, TransportTask task, Request.Content bodyContent + HttpMethod method, TransportTask task, Request.Content bodyContent, HttpClient client ) { final String url = this.baseUri.resolve(task.getLocation()).toString(); if (this.authInfo != null) { - this.client.getAuthenticationStore().addAuthenticationResult( + client.getAuthenticationStore().addAuthenticationResult( new BasicAuthentication.BasicResult(this.baseUri, this.authInfo[0], this.authInfo[1]) ); } + Request request = null; + final HttpVersion version = this.httpVersion(client); try { InputStreamResponseListener listener = new InputStreamResponseListener(); - this.client.newRequest(url).method(method).headers( + request = client.newRequest(url); + request.method(method).headers( httpFields -> { if (bodyContent != null) { httpFields.add(HttpHeader.CONTENT_TYPE, bodyContent.getContentType()); @@ -245,22 +294,26 @@ private Pair makeRequest( final Response response = listener.get(this.connectTimeout, TimeUnit.MILLISECONDS); if (response.getStatus() >= 300) { System.err.printf( - "Request over HTTP3 error status %s, method=%s, url=%s%n", - response.getStatus(), method, url + "Request over %s error status %s, method=%s, url=%s%n", + version, response.getStatus(), method, url ); throw new HttpResponseException(Integer.toString(response.getStatus()), response); } System.err.printf( - "Request over HTTP3 done, method=%s, resp status=%s, url=%s%n", - method, response.getStatus(), url + "Request over %s done, method=%s, resp status=%s, url=%s%n", + version, method, response.getStatus(), url ); return new ImmutablePair<>(listener.getInputStream(), response.getHeaders()); } catch (Exception ex) { System.err.printf( - "Request over HTTP3 error=%s: %s, method=%s, url=%s%n", + "Request over %s error=%s: %s, method=%s, url=%s%n", version, ex.getClass(), ex.getMessage(), method, url ); - throw new HttpRequestException(ex.getMessage(), this.client.newRequest(url)); + if (version == HttpVersion.HTTP_3 && ex instanceof TimeoutException) { + System.err.printf("Repeat request over HTTP/1.1 method=%s, url=%s%n", method, url); + return this.makeRequest(method, task, bodyContent, this.initOrGetHttpClient()); + } + throw new HttpRequestException(ex.getMessage(), request); } } @@ -274,6 +327,24 @@ private void extractChecksums(HttpFields response, GetTask task) { } } + /** + * Choose http client to initialize and perform request with: if host is present in known + * central's hosts {@link HttpTransporter#CENTRAL}, http 1.1 client is used, otherwise we use http3 client. + */ + private HttpClient chooseClient() { + final HttpClient res; + if (CENTRAL.contains(this.baseUri.getHost())) { + res = Optional.ofNullable(this.httpClient).orElseGet(this::initOrGetHttpClient); + } else { + res = Optional.ofNullable(this.http3Client).orElseGet(this::initOrGetHttp3Client); + } + return res; + } + + private HttpVersion httpVersion(final HttpClient client) { + return client.getTransport() instanceof HttpClientTransportOverHTTP3 ? HttpVersion.HTTP_3 : HttpVersion.HTTP_1_1; + } + /** * TOOD: For unknown reason when running inside Maven, HttpFieldPreEncoder for HTTP3 is missing. * It is not available in Jetty static initializer when that library is loaded by Maven. diff --git a/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/ArtipieHTTP3IT.java b/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/ArtipieHTTP3IT.java index 5911352..d6e0797 100644 --- a/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/ArtipieHTTP3IT.java +++ b/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/ArtipieHTTP3IT.java @@ -82,8 +82,8 @@ void resolvesDependencies() throws IOException, InterruptedException { res, Matchers.stringContainsInOrder( "BUILD SUCCESS", - "Request over HTTP3 done, method=GET, resp status=200, url=https://artipie:8091/my-maven-proxy/args4j/args4j/2.33/args4j-2.33.jar", - "Request over HTTP3 done, method=GET, resp status=200, url=https://artipie:8091/my-maven-proxy/org/springframework/spring-web/6.1.0/spring-web-6.1.0.jar" + "Request over HTTP/3.0 done, method=GET, resp status=200, url=https://artipie:8091/my-maven-proxy/args4j/args4j/2.33/args4j-2.33.jar", + "Request over HTTP/3.0 done, method=GET, resp status=200, url=https://artipie:8091/my-maven-proxy/org/springframework/spring-web/6.1.0/spring-web-6.1.0.jar" ) ); } diff --git a/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/MavenResolverIT.java b/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/MavenResolverIT.java index 2a34efd..13afc54 100644 --- a/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/MavenResolverIT.java +++ b/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/MavenResolverIT.java @@ -43,8 +43,8 @@ * Testing transport via containerized Caddy http3 server in proxy mode. */ public class MavenResolverIT { - private static final String REMOTE_PATH = "commons-cli/commons-cli/1.4/commons-cli-1.4.jar"; - private static final String LOCAL_PATH = "commons-cli-1.4.jar"; + static final String REMOTE_PATH = "commons-cli/commons-cli/1.4/commons-cli-1.4.jar"; + static final String LOCAL_PATH = "commons-cli-1.4.jar"; private static GenericContainer caddyProxy; private static File tempFile; @@ -53,7 +53,7 @@ public class MavenResolverIT { public void testTransporterAuth() throws Exception { final byte[] data = testTransporter("https://demo:demo@localhost:7444/maven2"); assertNotEquals(null, data); - final byte[] local = getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + final byte[] local = getCommonsJar(); assertArrayEquals(local, data); } @@ -79,7 +79,7 @@ public void testTransporterAnonAuthFail() { public void testTransporterAnon() throws Exception { final byte[] data = testTransporter("https://localhost:7443/maven2"); assertNotEquals(null, data); - final byte[] local = getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + final byte[] local = getCommonsJar(); assertArrayEquals(local, data); } @@ -87,7 +87,7 @@ public void testTransporterAnon() throws Exception { public void testAnonTransporterSuccess() throws Exception { final byte[] data = testTransporter("https://demo:demo@localhost:7443/maven2"); assertNotNull(data); - final byte[] local = getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + final byte[] local = getCommonsJar(); assertArrayEquals(local, data); } @@ -132,7 +132,7 @@ public void testJettyLocalhostPut() throws Exception { final HttpClient client = new HttpClient(transport); client.start(); h3Client.getClientConnector().getSslContextFactory().setTrustAll(true); - final byte[] srcData = getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + final byte[] srcData = getCommonsJar(); final ContentResponse response = client.newRequest("https://localhost:7445/test1"). method(HttpMethod.PUT).body( new InputStreamRequestContent(new ByteArrayInputStream(srcData)) @@ -150,7 +150,7 @@ public void testTransporterPutAnon() throws Exception { resetPutServer(); final HttpTransporterFactory factory = new HttpTransporterFactory(); final PutTask task = new PutTask(URI.create("test1")).setListener(new TransportListener() {}); - final byte[] srcData = getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + final byte[] srcData = getCommonsJar(); task.setDataBytes(srcData); try (final Transporter transporter = factory.newInstance(newSession(), newRepo(repo))) { transporter.put(task); @@ -166,7 +166,7 @@ public void testTransporterPutAuth() throws Exception { resetPutServer(); final HttpTransporterFactory factory = new HttpTransporterFactory(); final PutTask task = new PutTask(URI.create("test1")).setListener(new TransportListener() {}); - final byte[] srcData = getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + final byte[] srcData = getCommonsJar(); task.setDataBytes(srcData); try (final Transporter transporter = factory.newInstance(newSession(), newRepo(repo))) { transporter.put(task); @@ -209,14 +209,14 @@ public static void finish() { tempFile.delete(); } - private static DefaultRepositorySystemSession newSession() { + static DefaultRepositorySystemSession newSession() { DefaultRepositorySystemSession session = new DefaultRepositorySystemSession(); session.setLocalRepositoryManager(new TestLocalRepositoryManager()); session.setConfigProperty(ConfigurationProperties.HTTPS_SECURITY_MODE, ConfigurationProperties.HTTPS_SECURITY_MODE_INSECURE); return session; } - private RemoteRepository newRepo(final String url) { + static RemoteRepository newRepo(final String url) { return new RemoteRepository.Builder("test", "default", url).build(); } @@ -226,4 +226,8 @@ private static void resetPutServer() throws InterruptedException, IOException { assertEquals(0, result.getExitCode()); assertTrue(deleted); } + + byte[] getCommonsJar() throws IOException { + return getClass().getClassLoader().getResourceAsStream(LOCAL_PATH).readAllBytes(); + } } diff --git a/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/TransporterTest.java b/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/TransporterTest.java new file mode 100644 index 0000000..d3e0c46 --- /dev/null +++ b/mvn-resolver-transport-http3/src/test/java/com/artipie/aether/transport/http3/TransporterTest.java @@ -0,0 +1,126 @@ +package com.artipie.aether.transport.http3; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.eclipse.aether.spi.connector.transport.GetTask; +import org.eclipse.aether.spi.connector.transport.PeekTask; +import org.eclipse.aether.spi.connector.transport.PutTask; +import org.eclipse.aether.spi.connector.transport.TransportListener; +import org.eclipse.aether.spi.connector.transport.Transporter; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.Callback; +import org.hamcrest.MatcherAssert; +import org.hamcrest.core.IsEqual; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class TransporterTest { + + private int port; + + private Server server; + + private CountDownLatch latch; + + @BeforeEach + void init() throws Exception { + this.server = new Server(); + ServerConnector connector = new ServerConnector(server); + server.addConnector(connector); + latch = new CountDownLatch(1); + this.server.setHandler(new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + response.setStatus(200); + if ("GET".equals(request.getMethod())) { + response.write(true, ByteBuffer.wrap(getCommonsJar()), Callback.NOOP); + } else { + response.write(true, ByteBuffer.allocate(0), Callback.NOOP); + } + Content.Chunk chunk = request.read(); + if (chunk.hasRemaining()) { + MatcherAssert.assertThat( + chunk.getByteBuffer().array(), new IsEqual<>(getCommonsJar()) + ); + } + latch.countDown(); + return false; + } + }); + this.server.start(); + this.port = connector.getLocalPort(); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://repo.maven.apache.org/maven2/", + "https://oss.sonatype.org/content/repositories/releases/" + }) + void performsRequestToCentral(final String url) throws Exception { + final byte[] data = this.getResource(url); + MatcherAssert.assertThat(data, new IsEqual<>(this.getCommonsJar())); + } + + @Test + void getsResourceFromLocalhostViaHttp1() throws Exception { + final byte[] data = this.getResource(String.format("http://localhost:%d", this.port)); + MatcherAssert.assertThat(data, new IsEqual<>(this.getCommonsJar())); + } + + @Test + void performsHeadRequest() throws Exception { + final PeekTask task = new PeekTask(URI.create(MavenResolverIT.REMOTE_PATH)); + try (final Transporter transporter = new HttpTransporterFactory().newInstance( + MavenResolverIT.newSession(), + MavenResolverIT.newRepo(String.format("http://localhost:%d", this.port)) + )) { + transporter.peek(task); + } + MatcherAssert.assertThat("Head performed", latch.await(1, TimeUnit.MINUTES)); + } + + @Test + void performsPutRequest() throws Exception { + final PutTask task = new PutTask(URI.create(MavenResolverIT.REMOTE_PATH)) + .setListener(new TransportListener() {}); + try (final Transporter transporter = new HttpTransporterFactory().newInstance( + MavenResolverIT.newSession(), + MavenResolverIT.newRepo(String.format("http://localhost:%d", this.port)) + )) { + transporter.put(task); + } + MatcherAssert.assertThat("Put performed", latch.await(1, TimeUnit.MINUTES)); + } + + @AfterEach + void close() throws Exception { + this.server.stop(); + this.server.destroy(); + } + + private byte[] getResource(final String repo) throws Exception { + final GetTask task = new GetTask(URI.create(MavenResolverIT.REMOTE_PATH)) + .setListener(new TransportListener() {}); + try (final Transporter transporter = new HttpTransporterFactory() + .newInstance(MavenResolverIT.newSession(), MavenResolverIT.newRepo(repo))) { + transporter.get(task); + } + return task.getDataBytes(); + } + + byte[] getCommonsJar() throws IOException { + return getClass().getClassLoader().getResourceAsStream(MavenResolverIT.LOCAL_PATH).readAllBytes(); + } + +}