diff --git a/dependencies.toml b/dependencies.toml index 04aca5bdbf7..2c3759bb855 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -4,6 +4,7 @@ akka-http-cors = "1.0.0" akka-grpc-runtime = "1.0.3" apache-httpclient5 = "5.2.1" apache-httpclient4 = "4.5.14" +asm = "9.5" assertj = "3.24.2" awaitility = "4.2.0" blockhound = "1.0.8.RELEASE" @@ -52,8 +53,14 @@ javax-inject = "1" javax-jsr311 = "1.1.1" javax-validation = "2.0.1.Final" jctools = "4.0.1" -jetty93 = '9.3.30.v20211001' -jetty94 = "9.4.50.v20221201" +# Find the latest version of the major 10 https://central.sonatype.com/artifact/org.eclipse.jetty/jetty-server/ +jetty10 = "10.0.15" +# Find the latest version of the major 10 https://central.sonatype.com/artifact/org.eclipse.jetty/apache-jstl/ +jetty10-jstl = "10.0.15" +jetty11 = "11.0.15" +jetty11-jstl = "11.0.0" +jetty93 = "9.3.30.v20211001" +jetty94 = "9.4.51.v20230217" jetty-alpn-api = "1.1.3.v20160715" jetty-alpn-agent = "2.0.10" jmh-core = "1.36" @@ -71,6 +78,7 @@ kotlin = "1.8.10" kotlin-coroutine = "1.6.4" ktlint-gradle-plugin = "11.3.2" logback = "1.2.11" +logback14 = "1.4.7" micrometer = "1.10.5" micrometer13 = "1.3.20" mockito = "4.11.0" @@ -116,6 +124,7 @@ shadow-gradle-plugin = "7.1.2" shibboleth-utilities = "7.5.2" snappy = "1.1.9.1" slf4j = "1.7.36" +slf4j2 = "2.0.7" spring6 = "6.0.6" spring-boot2 = "2.7.10" spring-boot3 = "3.0.5" @@ -173,6 +182,11 @@ module = "org.apache.httpcomponents:httpclient" version.ref = "apache-httpclient4" exclusions = "commons-logging:commons-logging" +# This is only used for testing Jetty +[libraries.asm] +module = "org.ow2.asm:asm" +version.ref = "asm" + [libraries.assertj] module = "org.assertj:assertj-core" version.ref = "assertj" @@ -565,6 +579,40 @@ module = "org.jctools:jctools-core" version.ref = "jctools" relocations = { from = "org.jctools", to = "com.linecorp.armeria.internal.shaded.jctools" } +[libraries.jetty10-annotations] +module = "org.eclipse.jetty:jetty-annotations" +version.ref = "jetty10" +[libraries.jetty10-apache-jsp] +module = "org.eclipse.jetty:apache-jsp" +version.ref = "jetty10" +[libraries.jetty10-apache-jstl] +module = "org.eclipse.jetty:apache-jstl" +version.ref = "jetty10-jstl" +[libraries.jetty10-server] +module = "org.eclipse.jetty:jetty-server" +version.ref = "jetty10" +# jetty-webapp for testing interoperability with other servers. +[libraries.jetty10-webapp] +module = "org.eclipse.jetty:jetty-webapp" +version.ref = "jetty10" + +[libraries.jetty11-annotations] +module = "org.eclipse.jetty:jetty-annotations" +version.ref = "jetty11" +[libraries.jetty11-apache-jsp] +module = "org.eclipse.jetty:apache-jsp" +version.ref = "jetty11" +[libraries.jetty11-apache-jstl] +module = "org.eclipse.jetty:apache-jstl" +version.ref = "jetty11-jstl" +[libraries.jetty11-server] +module = "org.eclipse.jetty:jetty-server" +version.ref = "jetty11" +# jetty-webapp for testing interoperability with other servers. +[libraries.jetty11-webapp] +module = "org.eclipse.jetty:jetty-webapp" +version.ref = "jetty11" + [libraries.jetty93-annotations] module = "org.eclipse.jetty:jetty-annotations" exclusions = ["org.ow2.asm:asm", "org.ow2.asm:asm-commons"] @@ -705,6 +753,11 @@ module = "ch.qos.logback:logback-classic" version.ref = "logback" javadocs = "https://www.javadoc.io/doc/ch.qos.logback/logback-classic/1.2.12/" +[libraries.logback14] +module = "ch.qos.logback:logback-classic" +version.ref = "logback14" +javadocs = "https://www.javadoc.io/doc/ch.qos.logback/logback-classic/1.4.7/" + [libraries.micrometer-core] module = "io.micrometer:micrometer-core" version.ref = "micrometer" @@ -1026,6 +1079,11 @@ version.ref = "slf4j" module = "org.slf4j:slf4j-simple" version.ref = "slf4j" +[libraries.slf4j2-api] +module = "org.slf4j:slf4j-api" +version.ref = "slf4j2" +javadocs = "https://www.javadoc.io/doc/org.slf4j/slf4j-api/2.0.7/" + [libraries.spring6-web] module = "org.springframework:spring-web" version.ref = "spring6" @@ -1073,6 +1131,9 @@ javadocs = "https://docs.spring.io/spring/docs/current/javadoc-api/" [libraries.spring-boot3-starter-actuator] module = "org.springframework.boot:spring-boot-starter-actuator" version.ref = "spring-boot3" +[libraries.spring-boot3-starter-jetty] +module = "org.springframework.boot:spring-boot-starter-jetty" +version.ref = "spring-boot3" [libraries.spring-boot3-starter-security] module = "org.springframework.boot:spring-boot-starter-security" version.ref = "spring-boot3" diff --git a/examples/spring-boot-jetty/build.gradle b/examples/spring-boot-jetty/build.gradle new file mode 100644 index 00000000000..0d3f593ad0a --- /dev/null +++ b/examples/spring-boot-jetty/build.gradle @@ -0,0 +1,24 @@ +plugins { + alias libs.plugins.spring.boot +} + +dependencies { + implementation project(':core') + implementation project(':spring:boot3-starter') + implementation project(':jetty11') + + implementation libs.slf4j2.api + implementation libs.spring.boot3.starter.jetty + + implementation(libs.spring.boot3.starter.web) { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + annotationProcessor libs.spring.boot3.configuration.processor + + runtimeOnly project(':spring:boot3-actuator-starter') + + testImplementation libs.assertj + testImplementation libs.junit5.jupiter.api + testImplementation libs.logback14 + testImplementation libs.spring.boot3.starter.test +} diff --git a/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/HelloConfiguration.java b/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/HelloConfiguration.java new file mode 100644 index 00000000000..8f411881ffc --- /dev/null +++ b/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/HelloConfiguration.java @@ -0,0 +1,82 @@ +package example.springframework.boot.jetty; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.Loader; +import org.eclipse.jetty.webapp.WebAppContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer; +import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; +import org.springframework.boot.web.embedded.jetty.JettyWebServer; +import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.healthcheck.HealthChecker; +import com.linecorp.armeria.server.jetty.JettyService; +import com.linecorp.armeria.spring.ArmeriaServerConfigurator; + +import jakarta.servlet.Servlet; + +/** + * Configures an Armeria server to redirect the incoming requests to the Jetty instance provided by + * Spring Boot. It also sets up a {@link HealthChecker} so that it works well with a load balancer. + */ +@Configuration +public class HelloConfiguration { + + /** + * Returns a new {@link HealthChecker} that marks the server as unhealthy when Tomcat becomes unavailable. + */ + @Bean + public HealthChecker jettyHealthChecker(ServletWebServerApplicationContext applicationContext) { + final Server server = jettyServer(applicationContext).getServer(); + return server::isRunning; + } + + /** + * Returns a new {@link JettyService} that redirects the incoming requests to the Jetty instance + * provided by Spring Boot. + */ + @Bean + public JettyService jettyService(ServletWebServerApplicationContext applicationContext) { + final JettyWebServer jettyWebServer = jettyServer(applicationContext); + return JettyService.of(jettyWebServer.getServer(), null, false); + } + + /** + * Returns a new {@link ArmeriaServerConfigurator} that is responsible for configuring a {@link Server} + * using the given {@link ServerBuilder}. + */ + @Bean + public ArmeriaServerConfigurator armeriaServiceInitializer(JettyService jettyService) { + return sb -> sb.serviceUnder("/", jettyService); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class }) + static class EmbeddedJetty { + + @Bean + JettyServletWebServerFactory jettyServletWebServerFactory( + ObjectProvider serverCustomizers) { + final JettyServletWebServerFactory factory = new JettyServletWebServerFactory() { + + @Override + protected JettyWebServer getJettyWebServer(Server server) { + return new JettyWebServer(server, true); + } + }; + factory.getServerCustomizers().addAll(serverCustomizers.orderedStream().toList()); + return factory; + } + } + + /** + * Extracts a Jetty {@link Server} from Spring webapp context. + */ + private static JettyWebServer jettyServer(ServletWebServerApplicationContext applicationContext) { + return (JettyWebServer) applicationContext.getWebServer(); + } +} diff --git a/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/HelloController.java b/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/HelloController.java new file mode 100644 index 00000000000..cd4bdf3447f --- /dev/null +++ b/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/HelloController.java @@ -0,0 +1,18 @@ +package example.springframework.boot.jetty; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + @RequestMapping(method = RequestMethod.GET, path = "/") + String index() { + return "index"; + } + + @RequestMapping(method = RequestMethod.GET, path = "/hello") + String hello() { + return "Hello, World"; + } +} diff --git a/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/Main.java b/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/Main.java new file mode 100644 index 00000000000..3cc1d1430ae --- /dev/null +++ b/examples/spring-boot-jetty/src/main/java/example/springframework/boot/jetty/Main.java @@ -0,0 +1,11 @@ +package example.springframework.boot.jetty; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Main { + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } +} diff --git a/examples/spring-boot-jetty/src/main/resources/config/application.yml b/examples/spring-boot-jetty/src/main/resources/config/application.yml new file mode 100644 index 00000000000..d369da7bf13 --- /dev/null +++ b/examples/spring-boot-jetty/src/main/resources/config/application.yml @@ -0,0 +1,9 @@ +spring.profiles.active: local +# Prevent the embedded Tomcat from opening a TCP/IP port. +server.port: -1 +--- +spring.config.activate.on-profile: local +armeria: + ports: + - port: 8080 + protocols: HTTP diff --git a/examples/spring-boot-jetty/src/main/resources/static/.gitkeep b/examples/spring-boot-jetty/src/main/resources/static/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/examples/spring-boot-jetty/src/main/resources/templates/.gitkeep b/examples/spring-boot-jetty/src/main/resources/templates/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/examples/spring-boot-jetty/src/test/java/example/springframework/boot/jetty/HelloControllerTest.java b/examples/spring-boot-jetty/src/test/java/example/springframework/boot/jetty/HelloControllerTest.java new file mode 100644 index 00000000000..e7570a2b5e5 --- /dev/null +++ b/examples/spring-boot-jetty/src/test/java/example/springframework/boot/jetty/HelloControllerTest.java @@ -0,0 +1,33 @@ +package example.springframework.boot.jetty; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import jakarta.inject.Inject; + +@ActiveProfiles("testbed") +@WebMvcTest(HelloController.class) +class HelloControllerTest { + @Inject + private MockMvc mvc; + + @Test + void index() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/")) + .andExpect(status().isOk()) + .andExpect(content().string("index")); + } + + @Test + void hello() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/hello")) + .andExpect(status().isOk()) + .andExpect(content().string("Hello, World")); + } +} diff --git a/examples/spring-boot-jetty/src/test/java/example/springframework/boot/jetty/HelloIntegrationTest.java b/examples/spring-boot-jetty/src/test/java/example/springframework/boot/jetty/HelloIntegrationTest.java new file mode 100644 index 00000000000..8e1ce99e559 --- /dev/null +++ b/examples/spring-boot-jetty/src/test/java/example/springframework/boot/jetty/HelloIntegrationTest.java @@ -0,0 +1,60 @@ +package example.springframework.boot.jetty; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.server.Server; + +import jakarta.inject.Inject; + +@ActiveProfiles("testbed") +@SpringBootTest( + classes = { + HelloConfiguration.class, + HelloController.class + }, + webEnvironment = WebEnvironment.DEFINED_PORT) +@EnableAutoConfiguration +class HelloIntegrationTest { + + @Inject + private Server server; + private WebClient client; + + @BeforeEach + void initClient() { + if (client == null) { + client = WebClient.of("http://127.0.0.1:" + server.activeLocalPort()); + } + } + + @Test + void index() { + final AggregatedHttpResponse res = client.get("/").aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("index"); + } + + @Test + void hello() throws Exception { + final AggregatedHttpResponse res = client.get("/hello").aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("Hello, World"); + } + + @Test + void healthCheck() throws Exception { + final AggregatedHttpResponse res = client.get("/internal/healthcheck").aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("{\"healthy\":true}"); + } +} diff --git a/examples/spring-boot-jetty/src/test/resources/application-testbed.yml b/examples/spring-boot-jetty/src/test/resources/application-testbed.yml new file mode 100644 index 00000000000..b1c8f5f29ec --- /dev/null +++ b/examples/spring-boot-jetty/src/test/resources/application-testbed.yml @@ -0,0 +1,7 @@ +# Prevent the embedded Tomcat from opening a TCP/IP port. +server.port: -1 +--- +armeria: + ports: + - port: 0 + protocols: HTTP diff --git a/examples/spring-boot-tomcat/src/main/java/example/springframework/boot/tomcat/HelloConfiguration.java b/examples/spring-boot-tomcat/src/main/java/example/springframework/boot/tomcat/HelloConfiguration.java index 7b09ea24ab2..2911c697611 100644 --- a/examples/spring-boot-tomcat/src/main/java/example/springframework/boot/tomcat/HelloConfiguration.java +++ b/examples/spring-boot-tomcat/src/main/java/example/springframework/boot/tomcat/HelloConfiguration.java @@ -54,6 +54,6 @@ public TomcatService tomcatService(ServletWebServerApplicationContext applicatio */ @Bean public ArmeriaServerConfigurator armeriaServiceInitializer(TomcatService tomcatService) { - return sb -> sb.service("prefix:/", tomcatService); + return sb -> sb.serviceUnder("/", tomcatService); } } diff --git a/jetty/jetty10/build.gradle b/jetty/jetty10/build.gradle new file mode 100644 index 00000000000..95a5155872f --- /dev/null +++ b/jetty/jetty10/build.gradle @@ -0,0 +1,39 @@ +dependencies { + api libs.jetty10.server + + // Can't exclude slf4j 1.x because the core module uses it as an api configuration. + // If this becomes problem, we will refactor the core more and exclude the slf4j 1.x dependency. + implementation libs.slf4j2.api + + testImplementation libs.asm + testImplementation libs.jetty10.annotations + testImplementation libs.jetty10.apache.jsp + testImplementation libs.jetty10.apache.jstl + testImplementation libs.jetty10.webapp + testImplementation libs.logback14 +} + + +// Use the sources from ':jetty11'. +def jetty11ProjectDir = "${rootProject.projectDir}/jetty/jetty11" + +// Copy common files from jetty11 module to gen-src directory in order to use them as a source set. +tasks.register('generateSources', Copy.class) { + from "${jetty11ProjectDir}/src/main/java" + into "${project.ext.genSrcDir}/main/java" + exclude '**/DispatcherTypeUtil.java' + exclude '**/server/jetty/package-info.java' +} + +tasks.register('generateTestSources', Copy.class) { + from "${jetty11ProjectDir}/src/test/java" + into "${project.ext.genSrcDir}/test/java" + exclude '**/AsyncStreamingHandlerFunction.java' + exclude '**/JettyServiceTestUtil.java' +} + +tasks.generateSources.dependsOn(generateTestSources) +tasks.compileJava.dependsOn(generateSources) +tasks.compileTestJava.dependsOn(generateSources) + +tasks.processTestResources.from "${jetty11ProjectDir}/src/test/resources" diff --git a/jetty/jetty10/src/main/java/com/linecorp/armeria/server/jetty/DispatcherTypeUtil.java b/jetty/jetty10/src/main/java/com/linecorp/armeria/server/jetty/DispatcherTypeUtil.java new file mode 100644 index 00000000000..12028cf441d --- /dev/null +++ b/jetty/jetty10/src/main/java/com/linecorp/armeria/server/jetty/DispatcherTypeUtil.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import javax.servlet.DispatcherType; + +import org.eclipse.jetty.server.Request; + +/** + * Jetty 10 uses javax meanwhile Jetty 11 uses jakarta. + */ +final class DispatcherTypeUtil { + + static void setRequestType(Request jReq) { + jReq.setDispatcherType(DispatcherType.REQUEST); + } + + private DispatcherTypeUtil() {} +} diff --git a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/package-info.java b/jetty/jetty10/src/main/java/com/linecorp/armeria/server/jetty/package-info.java similarity index 100% rename from jetty9/src/main/java/com/linecorp/armeria/server/jetty/package-info.java rename to jetty/jetty10/src/main/java/com/linecorp/armeria/server/jetty/package-info.java diff --git a/jetty/jetty10/src/test/java/com/linecorp/armeria/server/jetty/AsyncStreamingHandlerFunction.java b/jetty/jetty10/src/test/java/com/linecorp/armeria/server/jetty/AsyncStreamingHandlerFunction.java new file mode 100644 index 00000000000..1feb5c6bfd4 --- /dev/null +++ b/jetty/jetty10/src/test/java/com/linecorp/armeria/server/jetty/AsyncStreamingHandlerFunction.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import javax.servlet.AsyncContext; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Strings; + +import com.linecorp.armeria.internal.testing.SimpleChannelHandler.ThrowingBiConsumer; +import com.linecorp.armeria.server.ServiceRequestContext; + +class AsyncStreamingHandlerFunction implements ThrowingBiConsumer { + + private static final Logger logger = LoggerFactory.getLogger(AsyncStreamingHandlerFunction.class); + /** + * A 128KiB full of characters. + */ + private static final byte[] chunk = Strings.repeat("0123456789abcdef", 128 * 1024 / 16) + .getBytes(StandardCharsets.US_ASCII); + + @Override + public void accept(Request req, Response res) { + final ServiceRequestContext ctx = ServiceRequestContext.current(); + final int totalSize = Integer.parseInt(ctx.pathParam("totalSize")); + final int chunkSize = Integer.parseInt(ctx.pathParam("chunkSize")); + final AsyncContext asyncCtx = req.startAsync(); + ctx.eventLoop().schedule(() -> { + res.setStatus(200); + stream(ctx, asyncCtx, res, totalSize, chunkSize); + }, 500, TimeUnit.MILLISECONDS); + } + + private static void stream(ServiceRequestContext ctx, AsyncContext asyncCtx, Response res, + int remainingBytes, int chunkSize) { + final int bytesToWrite; + final boolean lastChunk; + if (remainingBytes <= chunkSize) { + bytesToWrite = remainingBytes; + lastChunk = true; + } else { + bytesToWrite = chunkSize; + lastChunk = false; + } + + try { + final OutputStream out = res.getOutputStream(); + out.write(chunk, 0, bytesToWrite); + if (lastChunk) { + out.close(); + } else { + ctx.eventLoop().execute( + () -> stream(ctx, asyncCtx, res, + remainingBytes - bytesToWrite, chunkSize)); + } + } catch (Exception e) { + logger.warn("{} Unexpected exception:", ctx, e); + } finally { + if (lastChunk) { + asyncCtx.complete(); + } + } + } +} diff --git a/jetty/jetty10/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTestUtil.java b/jetty/jetty10/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTestUtil.java new file mode 100644 index 00000000000..f3e762d8ca6 --- /dev/null +++ b/jetty/jetty10/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTestUtil.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.handler.AbstractHandler; + +import com.linecorp.armeria.internal.testing.SimpleChannelHandler.ThrowingBiConsumer; + +final class JettyServiceTestUtil { + + static JettyService newJettyService(ThrowingBiConsumer func) { + return JettyService.builder() + .handler(new AbstractHandler() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws ServletException { + try { + func.accept(baseRequest, (Response) response); + } catch (Throwable t) { + throw new ServletException(t); + } + } + }) + .build(); + } + + private JettyServiceTestUtil() {} +} diff --git a/jetty/jetty11/build.gradle b/jetty/jetty11/build.gradle new file mode 100644 index 00000000000..b8f749b45e8 --- /dev/null +++ b/jetty/jetty11/build.gradle @@ -0,0 +1,14 @@ +dependencies { + api libs.jetty11.server + + // Can't exclude slf4j 1.x because the core module uses it as an api configuration. + // If this becomes problem, we will refactor the core more and exclude the slf4j 1.x dependency. + implementation libs.slf4j2.api + + testImplementation libs.asm + testImplementation libs.jetty11.annotations + testImplementation libs.jetty11.apache.jsp + testImplementation libs.jetty11.apache.jstl + testImplementation libs.jetty11.webapp + testImplementation libs.logback14 +} diff --git a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/AbstractArmeriaEndPoint.java similarity index 85% rename from jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java rename to jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/AbstractArmeriaEndPoint.java index c1461d7f5df..349bb595f71 100644 --- a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/AbstractArmeriaEndPoint.java @@ -32,10 +32,10 @@ import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.server.ServiceRequestContext; -final class ArmeriaEndPoint implements EndPoint { +abstract class AbstractArmeriaEndPoint implements EndPoint { - private static final AtomicReferenceFieldUpdater stateUpdater = - AtomicReferenceFieldUpdater.newUpdater(ArmeriaEndPoint.class, State.class, "state"); + private static final AtomicReferenceFieldUpdater stateUpdater = + AtomicReferenceFieldUpdater.newUpdater(AbstractArmeriaEndPoint.class, State.class, "state"); private final ServiceRequestContext ctx; private final String hostname; @@ -55,7 +55,7 @@ protected void needsFillInterest() {} protected void onIncompleteFlush() {} }; - ArmeriaEndPoint(ServiceRequestContext ctx, @Nullable String hostname) { + AbstractArmeriaEndPoint(ServiceRequestContext ctx, @Nullable String hostname) { this.ctx = ctx; this.hostname = hostname != null ? hostname : ctx.config().virtualHost().defaultHostname(); } @@ -101,11 +101,6 @@ public void setConnection(Connection connection) { this.connection = connection; } - @Override - public boolean isOptimizedForDirectBuffers() { - return false; - } - @Override @Nullable public Object getTransport() { @@ -142,10 +137,10 @@ public void shutdownOutput() { @Override public void close() { - close(null); + close0(null); } - private void close(@Nullable Throwable failure) { + void close0(@Nullable Throwable failure) { for (;;) { final State oldState = state; if (oldState == State.CLOSED) { @@ -153,11 +148,7 @@ private void close(@Nullable Throwable failure) { } if (stateUpdater.compareAndSet(this, oldState, State.CLOSED)) { - if (failure == null) { - onClose(); - } else { - onClose(failure); - } + onClose0(failure); } } } @@ -165,15 +156,14 @@ private void close(@Nullable Throwable failure) { @Override public void onOpen() {} - @Override - public void onClose() { - writeFlusher.onClose(); - fillInterest.onClose(); - } - - private void onClose(Throwable failure) { - writeFlusher.onFail(failure); - fillInterest.onFail(failure); + void onClose0(@Nullable Throwable failure) { + if (failure == null) { + writeFlusher.onClose(); + fillInterest.onClose(); + } else { + writeFlusher.onFail(failure); + fillInterest.onFail(failure); + } } @Override diff --git a/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/AbstractJettyServiceBuilder.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/AbstractJettyServiceBuilder.java new file mode 100644 index 00000000000..4a97c715ce8 --- /dev/null +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/AbstractJettyServiceBuilder.java @@ -0,0 +1,256 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import static java.util.Objects.requireNonNull; + +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.RequestLog; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.SessionIdManager; +import org.eclipse.jetty.server.handler.HandlerWrapper; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * A skeletal builder implementation for {@link JettyServiceBuilder} in Jetty 9 and Jetty 10+ modules. + */ +public abstract class AbstractJettyServiceBuilder { + + final ImmutableMap.Builder attrs = ImmutableMap.builder(); + final ImmutableList.Builder beans = ImmutableList.builder(); + final ImmutableList.Builder handlerWrappers = ImmutableList.builder(); + final ImmutableList.Builder> customizers = ImmutableList.builder(); + + @Nullable + String hostname; + @Nullable + Boolean dumpAfterStart; + @Nullable + Boolean dumpBeforeStop; + @Nullable + Handler handler; + @Nullable + RequestLog requestLog; + @Nullable + Function sessionIdManagerFactory; + @Nullable + Long stopTimeoutMillis; + boolean tlsReverseDnsLookup; + + AbstractJettyServiceBuilder() {} + + /** + * Sets the default hostname of the Jetty {@link Server}. + */ + public AbstractJettyServiceBuilder hostname(String hostname) { + this.hostname = requireNonNull(hostname, "hostname"); + return this; + } + + /** + * Puts the specified attribute into the Jetty {@link Server}. + * + * @see Server#setAttribute(String, Object) + */ + public AbstractJettyServiceBuilder attr(String name, Object attribute) { + attrs.put(requireNonNull(name, "name"), requireNonNull(attribute, "attribute")); + return this; + } + + /** + * Adds the specified bean to the Jetty {@link Server}. + * + * @see Server#addBean(Object) + */ + public AbstractJettyServiceBuilder bean(Object bean) { + beans.add(new Bean(bean, null)); + return this; + } + + /** + * Adds the specified bean to the Jetty {@link Server}. + * + * @see Server#addBean(Object, boolean) + */ + public AbstractJettyServiceBuilder bean(Object bean, boolean managed) { + beans.add(new Bean(bean, managed)); + return this; + } + + /** + * Sets whether the Jetty {@link Server} needs to dump its configuration after it started up. + * + * @see Server#setDumpAfterStart(boolean) + */ + public AbstractJettyServiceBuilder dumpAfterStart(boolean dumpAfterStart) { + this.dumpAfterStart = dumpAfterStart; + return this; + } + + /** + * Sets whether the Jetty {@link Server} needs to dump its configuration before it shuts down. + * + * @see Server#setDumpBeforeStop(boolean) + */ + public AbstractJettyServiceBuilder dumpBeforeStop(boolean dumpBeforeStop) { + this.dumpBeforeStop = dumpBeforeStop; + return this; + } + + /** + * Sets the {@link Handler} of the Jetty {@link Server}. + * + * @see Server#setHandler(Handler) + */ + public AbstractJettyServiceBuilder handler(Handler handler) { + this.handler = requireNonNull(handler, "handler"); + return this; + } + + /** + * Adds the specified {@link HandlerWrapper} to the Jetty {@link Server}. + * + * @see Server#insertHandler(HandlerWrapper) + */ + public AbstractJettyServiceBuilder handlerWrapper(HandlerWrapper handlerWrapper) { + handlerWrappers.add(requireNonNull(handlerWrapper, "handlerWrapper")); + return this; + } + + /** + * Adds the specified {@link HttpConfiguration} to the Jetty {@link Server}. + * This method is a type-safe alias of {@link #bean(Object)}. + */ + public AbstractJettyServiceBuilder httpConfiguration(HttpConfiguration httpConfiguration) { + return bean(httpConfiguration); + } + + /** + * Sets the {@link RequestLog} of the Jetty {@link Server}. + * + * @see Server#setRequestLog(RequestLog) + */ + public AbstractJettyServiceBuilder requestLog(RequestLog requestLog) { + this.requestLog = requireNonNull(requestLog, "requestLog"); + return this; + } + + /** + * Sets the {@link SessionIdManager} of the Jetty {@link Server}. This method is a shortcut for: + *
{@code
+     * sessionIdManagerFactory(server -> sessionIdManager);
+     * }
+ * + * @see Server#setSessionIdManager(SessionIdManager) + */ + public AbstractJettyServiceBuilder sessionIdManager(SessionIdManager sessionIdManager) { + requireNonNull(sessionIdManager, "sessionIdManager"); + return sessionIdManagerFactory(server -> sessionIdManager); + } + + /** + * Sets the factory that creates a new instance of {@link SessionIdManager} for the Jetty {@link Server}. + * + * @see Server#setSessionIdManager(SessionIdManager) + */ + public AbstractJettyServiceBuilder sessionIdManagerFactory( + Function sessionIdManagerFactory) { + requireNonNull(sessionIdManagerFactory, "sessionIdManagerFactory"); + this.sessionIdManagerFactory = sessionIdManagerFactory; + return this; + } + + /** + * Sets the graceful stop time of the {@link Server#stop()} in milliseconds. + * + * @see Server#setStopTimeout(long) + */ + public AbstractJettyServiceBuilder stopTimeoutMillis(long stopTimeoutMillis) { + this.stopTimeoutMillis = stopTimeoutMillis; + return this; + } + + /** + * Sets whether Jetty has to perform reverse DNS lookup for the remote IP address on a TLS connection. + * By default, this flag is disabled because it is known to cause performance issues when the DNS server + * is not responsive enough. However, you might want to take the risk and enable it if you want the same + * behavior with Jetty 9.3 when mTLS is enabled. + * + * @see Jetty issue #1235 + * @see Jetty commit de7c146 + */ + public AbstractJettyServiceBuilder tlsReverseDnsLookup(boolean tlsReverseDnsLookup) { + this.tlsReverseDnsLookup = tlsReverseDnsLookup; + return this; + } + + /** + * Adds a {@link Consumer} that performs additional configuration operations against + * the Jetty {@link Server} created by a {@link JettyService}. + */ + public AbstractJettyServiceBuilder customizer(Consumer customizer) { + customizers.add(requireNonNull(customizer, "customizer")); + return this; + } + + /** + * Adds a {@link Consumer} that performs additional configuration operations against + * the Jetty {@link Server} created by a {@link JettyService}. + * + * @deprecated Use {@link #customizer(Consumer)}. + */ + @Deprecated + public AbstractJettyServiceBuilder configurator(Consumer configurator) { + return customizer(requireNonNull(configurator, "configurator")); + } + + static final class Bean { + + private final Object bean; + @Nullable + private final Boolean managed; + + Bean(Object bean, @Nullable Boolean managed) { + this.bean = requireNonNull(bean, "bean"); + this.managed = managed; + } + + Object bean() { + return bean; + } + + @Nullable + Boolean isManaged() { + return managed; + } + + @Override + public String toString() { + final String mode = managed != null ? managed ? "managed" : "unmanaged" + : "auto"; + return "(" + bean + ", " + mode + ')'; + } + } +} diff --git a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaConnector.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaConnector.java similarity index 95% rename from jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaConnector.java rename to jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaConnector.java index 279e5ce4a0e..cfa7e1ba5c9 100644 --- a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaConnector.java +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaConnector.java @@ -20,7 +20,7 @@ import java.nio.channels.ServerSocketChannel; import java.util.Collections; import java.util.List; -import java.util.concurrent.Future; +import java.util.concurrent.CompletableFuture; import org.eclipse.jetty.io.Connection; import org.eclipse.jetty.io.EndPoint; @@ -31,8 +31,7 @@ import org.eclipse.jetty.server.ServerConnector; import com.linecorp.armeria.common.util.SystemInfo; - -import io.netty.util.concurrent.GlobalEventExecutor; +import com.linecorp.armeria.common.util.UnmodifiableFuture; final class ArmeriaConnector extends ServerConnector { @@ -131,9 +130,9 @@ protected ServerSocketChannel openAcceptChannel() throws IOException { public void close() {} @Override - public Future shutdown() { + public CompletableFuture shutdown() { isShutdown = true; - return GlobalEventExecutor.INSTANCE.newSucceededFuture(null); + return UnmodifiableFuture.completedFuture(null); } @Override diff --git a/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java new file mode 100644 index 00000000000..3ab60c6b57f --- /dev/null +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.server.ServiceRequestContext; + +final class ArmeriaEndPoint extends AbstractArmeriaEndPoint { + + ArmeriaEndPoint(ServiceRequestContext ctx, @Nullable String hostname) { + super(ctx, hostname); + } + + @Override + public void close(Throwable cause) { + close0(cause); + } + + @Override + public void onClose(Throwable cause) { + onClose0(cause); + } +} diff --git a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaThreadPool.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaThreadPool.java similarity index 100% rename from jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaThreadPool.java rename to jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaThreadPool.java diff --git a/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/DispatcherTypeUtil.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/DispatcherTypeUtil.java new file mode 100644 index 00000000000..4d7065cbf08 --- /dev/null +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/DispatcherTypeUtil.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import org.eclipse.jetty.server.Request; + +import jakarta.servlet.DispatcherType; + +/** + * Jetty 10 uses javax meanwhile Jetty 11 uses jakarta. + */ +final class DispatcherTypeUtil { + + static void setRequestType(Request jReq) { + jReq.setDispatcherType(DispatcherType.REQUEST); + } + + private DispatcherTypeUtil() {} +} diff --git a/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java new file mode 100644 index 00000000000..d0bda69a01c --- /dev/null +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java @@ -0,0 +1,495 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import static java.util.Objects.requireNonNull; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import javax.net.ssl.SSLSession; + +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpURI; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.http.MetaData; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.HttpChannel; +import org.eclipse.jetty.server.HttpChannelOverHttp; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnection; +import org.eclipse.jetty.server.HttpInput.Content; +import org.eclipse.jetty.server.HttpInput.EofContent; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.Callback; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.ExchangeType; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpHeaders; +import com.linecorp.armeria.common.HttpHeadersBuilder; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpObject; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpResponseWriter; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.ResponseHeadersBuilder; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.BlockingTaskExecutor; +import com.linecorp.armeria.common.util.CompletionActions; +import com.linecorp.armeria.internal.server.servlet.ServletTlsAttributes; +import com.linecorp.armeria.server.HttpService; +import com.linecorp.armeria.server.RoutingContext; +import com.linecorp.armeria.server.ServerListenerAdapter; +import com.linecorp.armeria.server.ServiceConfig; +import com.linecorp.armeria.server.ServiceRequestContext; + +import io.netty.buffer.ByteBuf; +import io.netty.util.AsciiString; + +/** + * An {@link HttpService} that dispatches its requests to a web application running in an embedded + * Jetty. + * + * @see JettyServiceBuilder + */ +public final class JettyService implements HttpService { + + static final Logger logger = LoggerFactory.getLogger(JettyService.class); + + /** + * Creates a new {@link JettyService} from an existing Jetty {@link Server}. + * + * @param jettyServer the Jetty {@link Server} + */ + public static JettyService of(Server jettyServer) { + return of(jettyServer, null); + } + + /** + * Creates a new {@link JettyService} from an existing Jetty {@link Server}. + * + * @param jettyServer the Jetty {@link Server} + * @param hostname the default hostname, or {@code null} to use Armeria's default virtual host name. + */ + public static JettyService of(Server jettyServer, @Nullable String hostname) { + return of(jettyServer, hostname, false); + } + + /** + * Creates a new {@link JettyService} from an existing Jetty {@link Server}. + * + * @param jettyServer the Jetty {@link Server} + * @param hostname the default hostname, or {@code null} to use Armeria's default virtual host name. + * @param tlsReverseDnsLookup whether perform reverse DNS lookup for the remote IP address on a TLS + * connection. See {@link JettyServiceBuilder#tlsReverseDnsLookup(boolean)} + * for more information. + */ + public static JettyService of(Server jettyServer, @Nullable String hostname, boolean tlsReverseDnsLookup) { + requireNonNull(jettyServer, "jettyServer"); + return new JettyService(hostname, tlsReverseDnsLookup, blockingTaskExecutor -> jettyServer, + unused -> { /* unused */ }); + } + + /** + * Returns a new {@link JettyServiceBuilder}. + */ + public static JettyServiceBuilder builder() { + return new JettyServiceBuilder(); + } + + @Nullable + private final String hostname; + private final boolean tlsReverseDnsLookup; + private final Function serverFactory; + private final Consumer postStopTask; + + private final Configurator configurator; + + @Nullable + private Server jettyServer; + @Nullable + private ArmeriaConnector connector; + + private com.linecorp.armeria.server.@Nullable Server armeriaServer; + private boolean startedServer; + + JettyService(@Nullable String hostname, + boolean tlsReverseDnsLookup, + Function serverFactory, + Consumer postStopTask) { + this.hostname = hostname; + this.tlsReverseDnsLookup = tlsReverseDnsLookup; + this.serverFactory = serverFactory; + this.postStopTask = postStopTask; + configurator = new Configurator(); + } + + @Override + public void serviceAdded(ServiceConfig cfg) { + if (armeriaServer != null) { + if (armeriaServer != cfg.server()) { + throw new IllegalStateException("cannot be added to more than one server"); + } else { + return; + } + } + + armeriaServer = cfg.server(); + armeriaServer.addListener(configurator); + } + + void start() throws Exception { + boolean success = false; + try { + assert armeriaServer != null; + jettyServer = serverFactory.apply(armeriaServer.config().blockingTaskExecutor()); + connector = new ArmeriaConnector(jettyServer, armeriaServer); + jettyServer.addConnector(connector); + + if (!jettyServer.isRunning()) { + logger.info("Starting an embedded Jetty: {}", jettyServer); + jettyServer.start(); + startedServer = true; + } else { + startedServer = false; + } + success = true; + } finally { + if (!success) { + jettyServer = null; + connector = null; + } + } + } + + void stop() { + final Server jettyServer = this.jettyServer; + this.jettyServer = null; + connector = null; + + if (jettyServer == null || !startedServer) { + return; + } + + try { + logger.info("Stopping an embedded Jetty: {}", jettyServer); + jettyServer.stop(); + } catch (Exception e) { + logger.warn("Failed to stop an embedded Jetty: {}", jettyServer, e); + } + + postStopTask.accept(jettyServer); + } + + @Override + public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { + final ArmeriaConnector connector = this.connector; + assert connector != null; + + final HttpResponseWriter res = HttpResponse.streaming(); + + req.aggregate().handle((aReq, cause) -> { + if (cause != null) { + logger.warn("{} Failed to aggregate a request:", ctx, cause); + if (res.tryWrite(ResponseHeaders.of(HttpStatus.INTERNAL_SERVER_ERROR))) { + res.close(); + } + return null; + } + + try { + final HttpConfiguration httpConfiguration = connector.getHttpConfiguration(); + final ArmeriaEndPoint endPoint = new ArmeriaEndPoint(ctx, hostname); + final ArmeriaHttpConnection httpConnection = + new ArmeriaHttpConnection(httpConfiguration, connector, endPoint, + false, ctx, res, aReq.content()); + final HttpChannel httpChannel = httpConnection.getHttpChannel(); + + // Armeria has its own timeout mechanism. Disable Jetty's timeout scheduler + // and abort the Jetty transport when Armeria request is timed out. + httpChannel.getState().setTimeout(0); + ctx.whenRequestCancelling().handle((cancellationCause, unused) -> { + httpChannel.abort(cancellationCause); + return null; + }); + + final Request jReq = httpChannel.getRequest(); + fillRequest(ctx, aReq, jReq); + final SSLSession sslSession = ctx.sslSession(); + final boolean needsReverseDnsLookup; + if (sslSession != null) { + needsReverseDnsLookup = tlsReverseDnsLookup; + ServletTlsAttributes.fill(sslSession, jReq::setAttribute); + } else { + needsReverseDnsLookup = false; + } + + ctx.blockingTaskExecutor().execute(() -> { + // Perform a reverse DNS lookup if needed. + if (needsReverseDnsLookup) { + try { + ((InetSocketAddress) ctx.remoteAddress()).getHostName(); + } catch (Throwable t) { + logger.warn("{} Failed to perform a reverse DNS lookup:", ctx, t); + } + } + + // Let Jetty handle the request. + try { + httpChannel.handle(); + } catch (Throwable t) { + logger.warn("{} Failed to handle a request:", ctx, t); + } + }); + } catch (Throwable t) { + res.abort(t); + } + return null; + }).exceptionally(CompletionActions::log); + + return res; + } + + @Override + public ExchangeType exchangeType(RoutingContext routingContext) { + return ExchangeType.RESPONSE_STREAMING; + } + + private static void fillRequest( + ServiceRequestContext ctx, AggregatedHttpRequest aReq, Request jReq) { + DispatcherTypeUtil.setRequestType(jReq); + jReq.setAsyncSupported(true, null); + jReq.setSecure(ctx.sessionProtocol().isTls()); + jReq.setMetaData(toRequestMetadata(ctx, aReq)); + final HttpHeaders trailers = aReq.trailers(); + if (!trailers.isEmpty()) { + final HttpField[] httpFields = trailers.stream() + .map(e -> new HttpField(e.getKey().toString(), e.getValue())) + .toArray(HttpField[]::new); + jReq.setTrailerHttpFields(HttpFields.from(httpFields)); + } + } + + private static MetaData.Request toRequestMetadata(ServiceRequestContext ctx, AggregatedHttpRequest aReq) { + // Construct the HttpURI + final StringBuilder uriBuf = new StringBuilder(); + final RequestHeaders aHeaders = aReq.headers(); + + uriBuf.append(ctx.sessionProtocol().isTls() ? "https" : "http"); + uriBuf.append("://"); + uriBuf.append(aHeaders.authority()); + uriBuf.append(aHeaders.path()); + + final HttpURI uri = HttpURI.build(HttpURI.build(uriBuf.toString()).path(ctx.mappedPath())) + .asImmutable(); + final HttpField[] fields = aHeaders.stream().map(header -> { + final AsciiString k = header.getKey(); + final String v = header.getValue(); + if (k.charAt(0) != ':') { + return new HttpField(k.toString(), v); + } + if (HttpHeaderNames.AUTHORITY.equals(k) && !aHeaders.contains(HttpHeaderNames.HOST)) { + // Convert `:authority` to `host`. + return new HttpField(HttpHeaderNames.HOST.toString(), v); + } + return null; + }).filter(Objects::nonNull).toArray(HttpField[]::new); + final HttpFields jHeaders = HttpFields.from(fields); + + return new MetaData.Request(aHeaders.method().name(), uri, + ctx.sessionProtocol().isMultiplex() ? HttpVersion.HTTP_2 + : HttpVersion.HTTP_1_1, + jHeaders, aReq.content().length()); + } + + private static final class ArmeriaHttpConnection extends HttpConnection { + + private static final EofContent EOF_CONTENT = new EofContent(); + + private final ServiceRequestContext ctx; + private final HttpResponseWriter res; + @Nullable + private HttpData content; + + MetaData.@Nullable Response response; + + ArmeriaHttpConnection(HttpConfiguration config, Connector connector, + EndPoint endPoint, boolean recordComplianceViolations, + ServiceRequestContext ctx, HttpResponseWriter res, HttpData content) { + super(config, connector, endPoint, recordComplianceViolations); + this.ctx = ctx; + this.res = res; + this.content = content; + } + + @Override + protected HttpChannelOverHttp newHttpChannel() { + return new HttpChannelOverHttp(this, getConnector(), getHttpConfiguration(), getEndPoint(), this) { + @Override + public Content produceContent() { + if (content != null && !content.isEmpty()) { + final ByteBuf buf = content.byteBuf(); + final ByteBuffer nioBuf; + if (buf.nioBufferCount() == 1) { + nioBuf = buf.nioBuffer(); + } else { + nioBuf = ByteBuffer.wrap(content.array()); + } + content = null; + return new Content(nioBuf); + } + return EOF_CONTENT; + } + }; + } + + @Override + public void send(MetaData.Request unused, MetaData.@Nullable Response response, + @Nullable ByteBuffer content, boolean lastContent, Callback callback) { + if (ctx.isTimedOut()) { + // Silently discard the write request in case of timeout to match the behavior of Jetty. + callback.succeeded(); + return; + } + + try { + if (response != null) { + this.response = response; + write(toResponseHeaders(response)); + } + + final int length = content != null ? content.remaining() : 0; + if (ctx.request().headers().method() != HttpMethod.HEAD && length != 0) { + final HttpData data; + if (content.hasArray()) { + final int from = content.arrayOffset() + content.position(); + content.position(content.position() + length); + data = HttpData.wrap(Arrays.copyOfRange(content.array(), from, from + length)); + } else { + final byte[] buf = new byte[length]; + content.get(buf); + data = HttpData.wrap(buf); + } + + if (lastContent) { + final HttpHeaders trailers = toResponseTrailers(response); + if (trailers != null) { + write(data); + write(trailers); + } else { + write(data.withEndOfStream()); + } + res.close(); + } else { + write(data); + } + } else if (lastContent) { + final HttpHeaders trailers = toResponseTrailers(response); + if (trailers != null) { + write(trailers); + } + res.close(); + } + + callback.succeeded(); + } catch (Throwable cause) { + callback.failed(cause); + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private void write(HttpObject o) { + res.tryWrite(o); + } + + private static ResponseHeaders toResponseHeaders(MetaData.Response info) { + final ResponseHeadersBuilder headers = ResponseHeaders.builder(); + headers.status(info.getStatus()); + info.getFields().forEach(e -> headers.add(HttpHeaderNames.of(e.getName()), e.getValue())); + return headers.build(); + } + + @Nullable + private HttpHeaders toResponseTrailers(MetaData.@Nullable Response info) { + if (info == null) { + info = response; + if (info == null) { + return null; + } + } + + final Supplier trailerSupplier = info.getTrailerSupplier(); + if (trailerSupplier == null) { + return null; + } + + final HttpFields fields = trailerSupplier.get(); + if (fields == null || fields.size() == 0) { + return null; + } + + final HttpHeadersBuilder headers = HttpHeaders.builder(); + fields.forEach(e -> headers.add(HttpHeaderNames.of(e.getName()), e.getValue())); + return headers.build(); + } + + @Override + public boolean isPushSupported() { + return false; + } + + @Override + public void push(MetaData.Request request) {} + + @Override + public void onCompleted() { + res.close(); + } + + @Override + public void abort(Throwable failure) { + res.close(failure); + } + } + + private final class Configurator extends ServerListenerAdapter { + @Override + public void serverStarting(com.linecorp.armeria.server.Server server) throws Exception { + start(); + } + + @Override + public void serverStopped(com.linecorp.armeria.server.Server server) throws Exception { + stop(); + } + } +} diff --git a/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java new file mode 100644 index 00000000000..04091b5a1c1 --- /dev/null +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java @@ -0,0 +1,224 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import static java.util.Objects.requireNonNull; + +import java.util.EventListener; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.RequestLog; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.SessionIdManager; +import org.eclipse.jetty.server.handler.HandlerWrapper; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.BlockingTaskExecutor; +import com.linecorp.armeria.internal.common.util.ReentrantShortLock; + +/** + * Builds a {@link JettyService}. Use {@link JettyService#of(Server)} if you have a configured Jetty + * {@link Server} instance. + */ +public final class JettyServiceBuilder extends AbstractJettyServiceBuilder { + + private final ImmutableList.Builder eventListeners = ImmutableList.builder(); + + JettyServiceBuilder() {} + + /** + * Adds the specified event listener to the Jetty {@link Server}. + */ + public JettyServiceBuilder eventListener(EventListener eventListener) { + eventListeners.add(requireNonNull(eventListener, "eventListener")); + return this; + } + + @Override + public JettyServiceBuilder hostname(String hostname) { + return (JettyServiceBuilder) super.hostname(hostname); + } + + @Override + public JettyServiceBuilder attr(String name, Object attribute) { + return (JettyServiceBuilder) super.attr(name, attribute); + } + + @Override + public JettyServiceBuilder bean(Object bean) { + return (JettyServiceBuilder) super.bean(bean); + } + + @Override + public JettyServiceBuilder bean(Object bean, boolean managed) { + return (JettyServiceBuilder) super.bean(bean, managed); + } + + @Override + public JettyServiceBuilder dumpAfterStart(boolean dumpAfterStart) { + return (JettyServiceBuilder) super.dumpAfterStart(dumpAfterStart); + } + + @Override + public JettyServiceBuilder dumpBeforeStop(boolean dumpBeforeStop) { + return (JettyServiceBuilder) super.dumpBeforeStop(dumpBeforeStop); + } + + @Override + public JettyServiceBuilder handler(Handler handler) { + return (JettyServiceBuilder) super.handler(handler); + } + + @Override + public JettyServiceBuilder handlerWrapper(HandlerWrapper handlerWrapper) { + return (JettyServiceBuilder) super.handlerWrapper(handlerWrapper); + } + + @Override + public JettyServiceBuilder httpConfiguration(HttpConfiguration httpConfiguration) { + return (JettyServiceBuilder) super.httpConfiguration(httpConfiguration); + } + + @Override + public JettyServiceBuilder requestLog(RequestLog requestLog) { + return (JettyServiceBuilder) super.requestLog(requestLog); + } + + @Override + public JettyServiceBuilder sessionIdManager(SessionIdManager sessionIdManager) { + return (JettyServiceBuilder) super.sessionIdManager(sessionIdManager); + } + + @Override + public JettyServiceBuilder sessionIdManagerFactory( + Function sessionIdManagerFactory) { + return (JettyServiceBuilder) super.sessionIdManagerFactory(sessionIdManagerFactory); + } + + @Override + public JettyServiceBuilder stopTimeoutMillis(long stopTimeoutMillis) { + return (JettyServiceBuilder) super.stopTimeoutMillis(stopTimeoutMillis); + } + + @Override + public JettyServiceBuilder tlsReverseDnsLookup(boolean tlsReverseDnsLookup) { + return (JettyServiceBuilder) super.tlsReverseDnsLookup(tlsReverseDnsLookup); + } + + @Override + public JettyServiceBuilder customizer(Consumer customizer) { + return (JettyServiceBuilder) super.customizer(customizer); + } + + @Override + public JettyServiceBuilder configurator(Consumer configurator) { + return (JettyServiceBuilder) super.configurator(configurator); + } + + /** + * Returns a newly-created {@link JettyService} based on the properties of this builder. + */ + public JettyService build() { + // Make a copy of the properties that's used in `serverFactory` so that any further modification of + // this builder doesn't affect the built server. + final Boolean dumpAfterStart = this.dumpAfterStart; + final Boolean dumpBeforeStop = this.dumpBeforeStop; + final Long stopTimeoutMillis = this.stopTimeoutMillis; + final Handler handler = this.handler; + final RequestLog requestLog = this.requestLog; + final Function sessionIdManagerFactory = + this.sessionIdManagerFactory; + final Map attrs = this.attrs.build(); + final List beans = this.beans.build(); + final List handlerWrappers = this.handlerWrappers.build(); + final List eventListeners = this.eventListeners.build(); + final List> customizers = this.customizers.build(); + + final Function serverFactory = new Function<>() { + + private final ReentrantShortLock lock = new ReentrantShortLock(); + @Nullable + private Server server; + + @Override + public Server apply(BlockingTaskExecutor blockingTaskExecutor) { + lock.lock(); + try { + if (server != null) { + return server; + } + server = new Server(new ArmeriaThreadPool(blockingTaskExecutor)); + + if (dumpAfterStart != null) { + server.setDumpAfterStart(dumpAfterStart); + } + if (dumpBeforeStop != null) { + server.setDumpBeforeStop(dumpBeforeStop); + } + if (stopTimeoutMillis != null) { + server.setStopTimeout(stopTimeoutMillis); + } + + if (handler != null) { + server.setHandler(handler); + } + if (requestLog != null) { + server.setRequestLog(requestLog); + } + if (sessionIdManagerFactory != null) { + server.setSessionIdManager(sessionIdManagerFactory.apply(server)); + } + + handlerWrappers.forEach(server::insertHandler); + attrs.forEach(server::setAttribute); + beans.forEach(bean -> { + final Boolean managed = bean.isManaged(); + if (managed == null) { + server.addBean(bean.bean()); + } else { + server.addBean(bean.bean(), managed); + } + }); + + eventListeners.forEach(server::addEventListener); + + customizers.forEach(c -> c.accept(server)); + return server; + } finally { + lock.unlock(); + } + } + }; + + final Consumer postStopTask = server -> { + try { + JettyService.logger.info("Destroying an embedded Jetty: {}", server); + server.destroy(); + } catch (Exception e) { + JettyService.logger.warn("Failed to destroy an embedded Jetty: {}", server, e); + } + }; + return new JettyService(hostname, tlsReverseDnsLookup, serverFactory, postStopTask); + } +} diff --git a/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/package-info.java b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/package-info.java new file mode 100644 index 00000000000..809365bb52f --- /dev/null +++ b/jetty/jetty11/src/main/java/com/linecorp/armeria/server/jetty/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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. + */ + +/** + * Embedded Jetty service. + */ +@NonNullByDefault +package com.linecorp.armeria.server.jetty; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/AsyncStreamingHandlerFunction.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/AsyncStreamingHandlerFunction.java new file mode 100644 index 00000000000..25228fd93b0 --- /dev/null +++ b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/AsyncStreamingHandlerFunction.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Strings; + +import com.linecorp.armeria.internal.testing.SimpleChannelHandler.ThrowingBiConsumer; +import com.linecorp.armeria.server.ServiceRequestContext; + +import jakarta.servlet.AsyncContext; + +class AsyncStreamingHandlerFunction implements ThrowingBiConsumer { + + private static final Logger logger = LoggerFactory.getLogger(AsyncStreamingHandlerFunction.class); + /** + * A 128KiB full of characters. + */ + private static final byte[] chunk = Strings.repeat("0123456789abcdef", 128 * 1024 / 16) + .getBytes(StandardCharsets.US_ASCII); + + @Override + public void accept(Request req, Response res) { + final ServiceRequestContext ctx = ServiceRequestContext.current(); + final int totalSize = Integer.parseInt(ctx.pathParam("totalSize")); + final int chunkSize = Integer.parseInt(ctx.pathParam("chunkSize")); + final AsyncContext asyncCtx = req.startAsync(); + ctx.eventLoop().schedule(() -> { + res.setStatus(200); + stream(ctx, asyncCtx, res, totalSize, chunkSize); + }, 500, TimeUnit.MILLISECONDS); + } + + private static void stream(ServiceRequestContext ctx, AsyncContext asyncCtx, Response res, + int remainingBytes, int chunkSize) { + final int bytesToWrite; + final boolean lastChunk; + if (remainingBytes <= chunkSize) { + bytesToWrite = remainingBytes; + lastChunk = true; + } else { + bytesToWrite = chunkSize; + lastChunk = false; + } + + try { + final OutputStream out = res.getOutputStream(); + out.write(chunk, 0, bytesToWrite); + if (lastChunk) { + out.close(); + } else { + ctx.eventLoop().execute( + () -> stream(ctx, asyncCtx, res, + remainingBytes - bytesToWrite, chunkSize)); + } + } catch (Exception e) { + logger.warn("{} Unexpected exception:", ctx, e); + } finally { + if (lastChunk) { + asyncCtx.complete(); + } + } + } +} diff --git a/jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceMutualTlsTest.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceMutualTlsTest.java similarity index 100% rename from jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceMutualTlsTest.java rename to jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceMutualTlsTest.java diff --git a/jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceStartupTest.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceStartupTest.java similarity index 100% rename from jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceStartupTest.java rename to jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceStartupTest.java diff --git a/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java new file mode 100644 index 00000000000..72054d90ba0 --- /dev/null +++ b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java @@ -0,0 +1,382 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import static com.linecorp.armeria.server.jetty.JettyServiceTestUtil.newJettyService; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.jetty.annotations.ServletContainerInitializersStarter; +import org.eclipse.jetty.apache.jsp.JettyJasperInitializer; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.io.EofException; +import org.eclipse.jetty.plus.annotation.ContainerInitializer; +import org.eclipse.jetty.server.handler.DefaultHandler; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.thread.ThreadPool; +import org.eclipse.jetty.webapp.WebAppContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +import com.linecorp.armeria.client.ResponseTimeoutException; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpHeaders; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.internal.testing.webapp.WebAppContainerTest; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.logging.LoggingService; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class JettyServiceTest extends WebAppContainerTest { + + private static final List jettyBeans = new ArrayList<>(); + + /** + * Indicates no exceptions were captured in {@link #capturedException}. + */ + private static final Exception NO_EXCEPTION = new Exception(); + + /** + * Captures the exception raised in a Jetty handler block. + */ + private static final AtomicReference capturedException = new AtomicReference<>(); + private static final Exception RUNTIME_EXCEPTION = new RuntimeException("RUNTIME_EXCEPTION"); + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.http(0); + sb.https(0); + sb.tlsSelfSigned(); + + sb.serviceUnder( + "/jsp/", + JettyService.builder() + .handler(newWebAppContext()) + .customizer(s -> jettyBeans.addAll(s.getBeans())) + .build() + .decorate(LoggingService.newDecorator())); + + sb.serviceUnder( + "/default/", + JettyService.builder() + .handler(new DefaultHandler()) + .build()); + + final ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setResourceBase(webAppRoot().getPath()); + sb.serviceUnder( + "/resources/", + JettyService.builder() + .handler(resourceHandler) + .build()); + + sb.service( + "/headers-only", + newJettyService((req, res) -> { + res.setStatus(204); + res.addHeader("x-headers", "foo"); + res.getOutputStream().close(); + })); + + sb.service( + "/headers-trailers", + newJettyService((req, res) -> { + res.setStatus(200); + res.addHeader("x-headers", "bar"); + res.setTrailers(() -> HttpFields.from(new HttpField("x-trailers", "baz"))); + res.getOutputStream().close(); + })); + + sb.service("/headers-data-trailers", + newJettyService((req, res) -> { + res.setStatus(200); + res.addHeader("x-headers", "bar"); + res.setTrailers(() -> HttpFields.from(new HttpField("x-trailers", "baz"))); + final OutputStream out = res.getOutputStream(); + out.write("qux".getBytes()); + out.close(); + })); + + // Attempts to write after the request handling is completed due to timeout or disconnection, + // and captures the exception raised by {@link ServletOutputStream#println()}. + sb.service("/timeout/{timeout}", + newJettyService((req, res) -> { + final ServiceRequestContext ctx = ServiceRequestContext.current(); + ctx.setRequestTimeoutMillis(Integer.parseInt(ctx.pathParam("timeout"))); + res.setStatus(200); + final OutputStream out = res.getOutputStream(); + await().until(() -> ctx.log().isComplete()); + try { + out.write(System.lineSeparator().getBytes()); + out.close(); + capturedException.set(NO_EXCEPTION); + } catch (Throwable cause) { + capturedException.set(cause); + } + })); + + // Attempts to write again after closing the output stream, + // and captures the exception raised by the failed write attempt after close(). + sb.service("/write-after-completion", + newJettyService((req, res) -> { + res.setStatus(200); + final OutputStream out = res.getOutputStream(); + out.write("before close".getBytes()); + out.close(); + try { + out.write("after close".getBytes()); + capturedException.set(NO_EXCEPTION); + } catch (Throwable cause) { + capturedException.set(cause); + } + })); + + sb.service("/stream/{totalSize}/{chunkSize}", + newJettyService(new AsyncStreamingHandlerFunction())); + + sb.service("/throwing", + newJettyService((req, res) -> res.closeOutput()) + .decorate((delegate, ctx, req) -> { + ctx = spy(ctx); + // relies on the fact that JettyService calls this method + when(ctx.sessionProtocol()).thenThrow(RUNTIME_EXCEPTION); + return delegate.serve(ctx, req); + })); + } + }; + + static WebAppContext newWebAppContext() throws MalformedURLException { + final WebAppContext handler = new WebAppContext(); + handler.setContextPath("/"); + handler.setBaseResource(Resource.newResource(webAppRoot())); + handler.setClassLoader(new URLClassLoader( + new URL[] { + Resource.newResource(new File(webAppRoot(), + "WEB-INF" + File.separatorChar + + "lib" + File.separatorChar + + "hello.jar")).getURI().toURL() + }, + JettyService.class.getClassLoader())); + + handler.addBean(new ServletContainerInitializersStarter(handler), true); + handler.setAttribute( + "org.eclipse.jetty.containerInitializers", + Collections.singletonList(new ContainerInitializer(new JettyJasperInitializer(), null))); + return handler; + } + + @Override + protected ServerExtension server() { + return server; + } + + @Test + void configurator() throws Exception { + assertThat(jettyBeans) + .hasAtLeastOneElementOfType(ThreadPool.class) + .hasAtLeastOneElementOfType(WebAppContext.class); + } + + @Test + void defaultHandlerFavicon() throws Exception { + final AggregatedHttpResponse res = + WebClient.of() + .get(server.httpUri() + "/default/favicon.ico") + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.OK); + assertThat(res.contentType()).isEqualTo(MediaType.parse("image/x-icon")); + assertThat(res.content().length()).isGreaterThan(0); + } + + @Test + void resourceHandlerWithLargeResource() throws Exception { + testLarge("/resources/large.txt", true); + } + + @Test + void headersOnly() throws Exception { + final AggregatedHttpResponse res = + WebClient.of() + .get(server.httpUri() + "/headers-only") + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.NO_CONTENT); + assertThat(res.headers()).containsAll(HttpHeaders.of("x-headers", "foo")); + assertThat(res.trailers()).isEmpty(); + } + + @Test + void headersAndTrailers() throws Exception { + final AggregatedHttpResponse res = + WebClient.of() + .get(server.httpUri() + "/headers-trailers") + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.OK); + assertThat(res.headers()).containsAll(HttpHeaders.of("x-headers", "bar")); + assertThat(res.content().length()).isZero(); + assertThat(res.trailers()).containsAll(HttpHeaders.of("x-trailers", "baz")); + } + + @Test + void headersDataAndTrailers() throws Exception { + final AggregatedHttpResponse res = + WebClient.of() + .get(server.httpUri() + "/headers-data-trailers") + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.OK); + assertThat(res.headers()).containsAll(HttpHeaders.of("x-headers", "bar")); + assertThat(res.contentAscii()).isEqualTo("qux"); + assertThat(res.trailers()).containsAll(HttpHeaders.of("x-trailers", "baz")); + } + + /** + * An {@link IOException} or {@link EofException} should be raised if a handler closed + * its {@code ServletOutputStream} and then tries to write something to it. + */ + @Test + void writingAfterCompletion() { + capturedException.set(null); + final AggregatedHttpResponse res = + WebClient.of() + .get(server.httpUri() + "/write-after-completion") + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("before close"); + // An `IOException` should be raised when writing after closing the `ServletOutputStream`. + await().untilAsserted(() -> assertThat(capturedException).isNotNull()); + final Throwable cause = capturedException.get(); + assertThat(cause).isInstanceOf(IOException.class) + .hasMessage("Closed"); + } + + @Test + @Override + public void echoPostWithEmptyBody() throws Exception { + super.echoPostWithEmptyBody(); + } + + @ParameterizedTest + @EnumSource(value = SessionProtocol.class, names = { "H1C", "H2C" }) + void sendingResponseOnDisconnectedConnection(SessionProtocol protocol) { + capturedException.set(null); + // Send a request that doesn't time out until the client gives up. + // The client will give up quickly and disconnect. + final String uri = protocol.uriText() + "://127.0.0.1:" + server.httpPort() + + "/timeout/" + Integer.MAX_VALUE; + assertThatThrownBy(() -> { + WebClient.of() + .prepare() + .get(uri) + .responseTimeoutMillis(1) + .execute() + .aggregate() + .join(); + }).isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ResponseTimeoutException.class); + + // No exception should be raised when a Jetty handler writes something after timeout. + await().untilAsserted(() -> assertThat(capturedException).hasValue(NO_EXCEPTION)); + } + + @ParameterizedTest + @EnumSource(value = SessionProtocol.class, names = { "H1C", "H2C" }) + void sendingResponseToTimedOutRequest(SessionProtocol protocol) { + capturedException.set(null); + // Send a request that times out after 1ms. + final String uri = protocol.uriText() + "://127.0.0.1:" + server.httpPort() + "/timeout/1"; + final AggregatedHttpResponse res = + WebClient.of() + .get(uri) + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.SERVICE_UNAVAILABLE); + // No exception should be raised when a Jetty handler writes something after timeout. + await().untilAsserted(() -> assertThat(capturedException).hasValue(NO_EXCEPTION)); + } + + /** + * Makes sure asynchronous streaming works for various sizes of responses. + */ + @ParameterizedTest + @CsvSource({ + "8192, 8192", // 8KiB in a single write + "8192, 128", // 8KiB in many writes + "131072, 131072", // 128KiB in a single write + "131072, 8192", // 128KiB in many writes + }) + void asyncRequest(int totalSize, int chunkSize) throws Exception { + final AggregatedHttpResponse res = + WebClient.of() + .get(server.httpUri() + "/stream/" + totalSize + '/' + chunkSize) + .aggregate() + .join(); + + assertThat(res.status()).isSameAs(HttpStatus.OK); + assertThat(res.contentAscii()).hasSize(totalSize) + .matches("^(?:0123456789abcdef)*$"); + } + + @ParameterizedTest + @EnumSource(value = SessionProtocol.class, names = {"H1C", "H2C"}) + void throwingHandler(SessionProtocol sessionProtocol) throws Exception { + final AggregatedHttpResponse res = WebClient.builder(sessionProtocol, server.httpEndpoint()) + .build().blocking().get("/throwing"); + assertThat(res.status().code()).isEqualTo(500); + + assertThat(server.requestContextCaptor().size()).isEqualTo(1); + final ServiceRequestContext sctx = server.requestContextCaptor().poll(); + await().atMost(10, TimeUnit.SECONDS).until(() -> sctx.log().isComplete()); + assertThat(sctx.log().ensureComplete().responseCause()).isSameAs(RUNTIME_EXCEPTION); + } +} diff --git a/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTestUtil.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTestUtil.java new file mode 100644 index 00000000000..bfdd50693ea --- /dev/null +++ b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTestUtil.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.handler.AbstractHandler; + +import com.linecorp.armeria.internal.testing.SimpleChannelHandler.ThrowingBiConsumer; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +final class JettyServiceTestUtil { + + static JettyService newJettyService(ThrowingBiConsumer func) { + return JettyService.builder() + .handler(new AbstractHandler() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws ServletException { + try { + func.accept(baseRequest, (Response) response); + } catch (Throwable t) { + throw new ServletException(t); + } + } + }) + .build(); + } + + private JettyServiceTestUtil() {} +} diff --git a/jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTlsReverseDnsLookupTest.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTlsReverseDnsLookupTest.java similarity index 100% rename from jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTlsReverseDnsLookupTest.java rename to jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTlsReverseDnsLookupTest.java diff --git a/jetty9/src/test/java/com/linecorp/armeria/server/jetty/UnmanagedJettyServiceTest.java b/jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/UnmanagedJettyServiceTest.java similarity index 100% rename from jetty9/src/test/java/com/linecorp/armeria/server/jetty/UnmanagedJettyServiceTest.java rename to jetty/jetty11/src/test/java/com/linecorp/armeria/server/jetty/UnmanagedJettyServiceTest.java diff --git a/jetty9/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/jetty/jetty11/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker similarity index 100% rename from jetty9/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker rename to jetty/jetty11/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/jetty9.3/build.gradle b/jetty/jetty9.3/build.gradle similarity index 100% rename from jetty9.3/build.gradle rename to jetty/jetty9.3/build.gradle diff --git a/jetty/jetty9/build.gradle b/jetty/jetty9/build.gradle new file mode 100644 index 00000000000..17a78d30f10 --- /dev/null +++ b/jetty/jetty9/build.gradle @@ -0,0 +1,36 @@ +dependencies { + // Jetty + api libs.jetty94.server + testImplementation libs.jetty94.annotations + testImplementation libs.jetty94.apache.jsp + testImplementation libs.jetty94.apache.jstl + testImplementation libs.jetty94.webapp +} + +// Use the sources from ':jetty11'. +def jetty11ProjectDir = "${rootProject.projectDir}/jetty/jetty11" + +// Copy common files from jetty11 module to gen-src directory in order to use them as a source set. +tasks.register('generateSources', Copy.class) { + from "${jetty11ProjectDir}/src/main/java" + into "${project.ext.genSrcDir}/main/java" + exclude '**/ArmeriaEndPoint.java' + exclude '**/DispatcherTypeUtil.java' + exclude '**/JettyService.java' + exclude '**/JettyServiceBuilder.java' + exclude '**/server/jetty/package-info.java' +} + +tasks.register('generateTestSources', Copy.class) { + from "${jetty11ProjectDir}/src/test/java" + into "${project.ext.genSrcDir}/test/java" + exclude '**/AsyncStreamingHandlerFunction.java' + exclude '**/JettyServiceTest.java' + exclude '**/JettyServiceTestUtil.java' +} + +tasks.generateSources.dependsOn(generateTestSources) +tasks.compileJava.dependsOn(generateSources) +tasks.compileTestJava.dependsOn(generateSources) + +tasks.processTestResources.from "${jetty11ProjectDir}/src/test/resources" diff --git a/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java new file mode 100644 index 00000000000..b492fc8e498 --- /dev/null +++ b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/ArmeriaEndPoint.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.server.ServiceRequestContext; + +final class ArmeriaEndPoint extends AbstractArmeriaEndPoint { + + ArmeriaEndPoint(ServiceRequestContext ctx, + @Nullable String hostname) { + super(ctx, hostname); + } + + @Override + public void onClose() { + onClose0(null); + } + + @Override + public boolean isOptimizedForDirectBuffers() { + return false; + } +} diff --git a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java similarity index 100% rename from jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java rename to jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyService.java diff --git a/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java new file mode 100644 index 00000000000..116af576060 --- /dev/null +++ b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java @@ -0,0 +1,220 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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.linecorp.armeria.server.jetty; + +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.RequestLog; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.SessionIdManager; +import org.eclipse.jetty.server.handler.HandlerWrapper; +import org.eclipse.jetty.util.component.Container; +import org.eclipse.jetty.util.component.LifeCycle; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.util.BlockingTaskExecutor; + +/** + * Builds a {@link JettyService}. Use {@link JettyService#of(Server)} if you have a configured Jetty + * {@link Server} instance. + */ +public final class JettyServiceBuilder extends AbstractJettyServiceBuilder { + + private final ImmutableList.Builder eventListeners = ImmutableList.builder(); + private final ImmutableList.Builder lifeCycleListeners = ImmutableList.builder(); + + JettyServiceBuilder() {} + + /** + * Adds the specified event listener to the Jetty {@link Server}. + */ + public JettyServiceBuilder eventListener(Container.Listener eventListener) { + eventListeners.add(requireNonNull(eventListener, "eventListener")); + return this; + } + + /** + * Adds the specified life cycle listener to the Jetty {@link Server}. + */ + public JettyServiceBuilder lifeCycleListener(LifeCycle.Listener lifeCycleListener) { + lifeCycleListeners.add(requireNonNull(lifeCycleListener, "lifeCycleListener")); + return this; + } + + @Override + public JettyServiceBuilder hostname(String hostname) { + return (JettyServiceBuilder) super.hostname(hostname); + } + + @Override + public JettyServiceBuilder attr(String name, Object attribute) { + return (JettyServiceBuilder) super.attr(name, attribute); + } + + @Override + public JettyServiceBuilder bean(Object bean) { + return (JettyServiceBuilder) super.bean(bean); + } + + @Override + public JettyServiceBuilder bean(Object bean, boolean managed) { + return (JettyServiceBuilder) super.bean(bean, managed); + } + + @Override + public JettyServiceBuilder dumpAfterStart(boolean dumpAfterStart) { + return (JettyServiceBuilder) super.dumpAfterStart(dumpAfterStart); + } + + @Override + public JettyServiceBuilder dumpBeforeStop(boolean dumpBeforeStop) { + return (JettyServiceBuilder) super.dumpBeforeStop(dumpBeforeStop); + } + + @Override + public JettyServiceBuilder handler(Handler handler) { + return (JettyServiceBuilder) super.handler(handler); + } + + @Override + public JettyServiceBuilder handlerWrapper(HandlerWrapper handlerWrapper) { + return (JettyServiceBuilder) super.handlerWrapper(handlerWrapper); + } + + @Override + public JettyServiceBuilder httpConfiguration(HttpConfiguration httpConfiguration) { + return (JettyServiceBuilder) super.httpConfiguration(httpConfiguration); + } + + @Override + public JettyServiceBuilder requestLog(RequestLog requestLog) { + return (JettyServiceBuilder) super.requestLog(requestLog); + } + + @Override + public JettyServiceBuilder sessionIdManager(SessionIdManager sessionIdManager) { + return (JettyServiceBuilder) super.sessionIdManager(sessionIdManager); + } + + @Override + public JettyServiceBuilder sessionIdManagerFactory( + Function sessionIdManagerFactory) { + return (JettyServiceBuilder) super.sessionIdManagerFactory(sessionIdManagerFactory); + } + + @Override + public JettyServiceBuilder stopTimeoutMillis(long stopTimeoutMillis) { + return (JettyServiceBuilder) super.stopTimeoutMillis(stopTimeoutMillis); + } + + @Override + public JettyServiceBuilder tlsReverseDnsLookup(boolean tlsReverseDnsLookup) { + return (JettyServiceBuilder) super.tlsReverseDnsLookup(tlsReverseDnsLookup); + } + + @Override + public JettyServiceBuilder customizer(Consumer customizer) { + return (JettyServiceBuilder) super.customizer(customizer); + } + + @Override + public JettyServiceBuilder configurator(Consumer configurator) { + return (JettyServiceBuilder) super.configurator(configurator); + } + + /** + * Returns a newly-created {@link JettyService} based on the properties of this builder. + */ + public JettyService build() { + // Make a copy of the properties that's used in `serverFactory` so that any further modification of + // this builder doesn't affect the built server. + final Boolean dumpAfterStart = this.dumpAfterStart; + final Boolean dumpBeforeStop = this.dumpBeforeStop; + final Long stopTimeoutMillis = this.stopTimeoutMillis; + final Handler handler = this.handler; + final RequestLog requestLog = this.requestLog; + final Function sessionIdManagerFactory = + this.sessionIdManagerFactory; + final Map attrs = this.attrs.build(); + final List beans = this.beans.build(); + final List handlerWrappers = this.handlerWrappers.build(); + final List eventListeners = this.eventListeners.build(); + final List lifeCycleListeners = this.lifeCycleListeners.build(); + final List> customizers = this.customizers.build(); + + final Function serverFactory = blockingTaskExecutor -> { + final Server server = new Server(new ArmeriaThreadPool(blockingTaskExecutor)); + + if (dumpAfterStart != null) { + server.setDumpAfterStart(dumpAfterStart); + } + if (dumpBeforeStop != null) { + server.setDumpBeforeStop(dumpBeforeStop); + } + if (stopTimeoutMillis != null) { + server.setStopTimeout(stopTimeoutMillis); + } + + if (handler != null) { + server.setHandler(handler); + } + if (requestLog != null) { + server.setRequestLog(requestLog); + } + if (sessionIdManagerFactory != null) { + server.setSessionIdManager(sessionIdManagerFactory.apply(server)); + } + + handlerWrappers.forEach(server::insertHandler); + attrs.forEach(server::setAttribute); + beans.forEach(bean -> { + final Boolean managed = bean.isManaged(); + if (managed == null) { + server.addBean(bean.bean()); + } else { + server.addBean(bean.bean(), managed); + } + }); + + eventListeners.forEach(server::addEventListener); + lifeCycleListeners.forEach(server::addLifeCycleListener); + + customizers.forEach(c -> c.accept(server)); + + return server; + }; + + final Consumer postStopTask = server -> { + try { + JettyService.logger.info("Destroying an embedded Jetty: {}", server); + server.destroy(); + } catch (Exception e) { + JettyService.logger.warn("Failed to destroy an embedded Jetty: {}", server, e); + } + }; + + return new JettyService(hostname, tlsReverseDnsLookup, serverFactory, postStopTask); + } +} diff --git a/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/package-info.java b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/package-info.java new file mode 100644 index 00000000000..809365bb52f --- /dev/null +++ b/jetty/jetty9/src/main/java/com/linecorp/armeria/server/jetty/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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. + */ + +/** + * Embedded Jetty service. + */ +@NonNullByDefault +package com.linecorp.armeria.server.jetty; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; diff --git a/jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java b/jetty/jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java similarity index 100% rename from jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java rename to jetty/jetty9/src/test/java/com/linecorp/armeria/server/jetty/JettyServiceTest.java diff --git a/jetty/jetty9/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/jetty/jetty9/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..1f0955d450f --- /dev/null +++ b/jetty/jetty9/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/jetty9/build.gradle b/jetty9/build.gradle deleted file mode 100644 index dfc16d9b637..00000000000 --- a/jetty9/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -dependencies { - // Jetty - api libs.jetty94.server - testImplementation libs.jetty94.webapp - testImplementation libs.jetty94.annotations - testImplementation libs.jetty94.apache.jsp - testImplementation libs.jetty94.apache.jstl -} diff --git a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java b/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java deleted file mode 100644 index c5e308f975e..00000000000 --- a/jetty9/src/main/java/com/linecorp/armeria/server/jetty/JettyServiceBuilder.java +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright 2016 LINE Corporation - * - * LINE Corporation licenses this file to you 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: - * - * https://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.linecorp.armeria.server.jetty; - -import static java.util.Objects.requireNonNull; - -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.function.Function; - -import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.HttpConfiguration; -import org.eclipse.jetty.server.RequestLog; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.SessionIdManager; -import org.eclipse.jetty.server.handler.HandlerWrapper; -import org.eclipse.jetty.util.component.Container; -import org.eclipse.jetty.util.component.LifeCycle; - -import com.google.common.base.MoreObjects; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; - -import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.util.BlockingTaskExecutor; - -/** - * Builds a {@link JettyService}. Use {@link JettyService#of(Server)} if you have a configured Jetty - * {@link Server} instance. - */ -public final class JettyServiceBuilder { - - private final ImmutableMap.Builder attrs = ImmutableMap.builder(); - private final ImmutableList.Builder beans = ImmutableList.builder(); - private final ImmutableList.Builder handlerWrappers = ImmutableList.builder(); - private final ImmutableList.Builder eventListeners = ImmutableList.builder(); - private final ImmutableList.Builder lifeCycleListeners = ImmutableList.builder(); - private final ImmutableList.Builder> customizers = ImmutableList.builder(); - - @Nullable - private String hostname; - @Nullable - private Boolean dumpAfterStart; - @Nullable - private Boolean dumpBeforeStop; - @Nullable - private Handler handler; - @Nullable - private RequestLog requestLog; - @Nullable - private Function sessionIdManagerFactory; - @Nullable - private Long stopTimeoutMillis; - private boolean tlsReverseDnsLookup; - - JettyServiceBuilder() {} - - /** - * Sets the default hostname of the Jetty {@link Server}. - */ - public JettyServiceBuilder hostname(String hostname) { - this.hostname = requireNonNull(hostname, "hostname"); - return this; - } - - /** - * Puts the specified attribute into the Jetty {@link Server}. - * - * @see Server#setAttribute(String, Object) - */ - public JettyServiceBuilder attr(String name, Object attribute) { - attrs.put(requireNonNull(name, "name"), requireNonNull(attribute, "attribute")); - return this; - } - - /** - * Adds the specified bean to the Jetty {@link Server}. - * - * @see Server#addBean(Object) - */ - public JettyServiceBuilder bean(Object bean) { - beans.add(new Bean(bean, null)); - return this; - } - - /** - * Adds the specified bean to the Jetty {@link Server}. - * - * @see Server#addBean(Object, boolean) - */ - public JettyServiceBuilder bean(Object bean, boolean managed) { - beans.add(new Bean(bean, managed)); - return this; - } - - /** - * Sets whether the Jetty {@link Server} needs to dump its configuration after it started up. - * - * @see Server#setDumpAfterStart(boolean) - */ - public JettyServiceBuilder dumpAfterStart(boolean dumpAfterStart) { - this.dumpAfterStart = dumpAfterStart; - return this; - } - - /** - * Sets whether the Jetty {@link Server} needs to dump its configuration before it shuts down. - * - * @see Server#setDumpBeforeStop(boolean) - */ - public JettyServiceBuilder dumpBeforeStop(boolean dumpBeforeStop) { - this.dumpBeforeStop = dumpBeforeStop; - return this; - } - - /** - * Sets the {@link Handler} of the Jetty {@link Server}. - * - * @see Server#setHandler(Handler) - */ - public JettyServiceBuilder handler(Handler handler) { - this.handler = requireNonNull(handler, "handler"); - return this; - } - - /** - * Adds the specified {@link HandlerWrapper} to the Jetty {@link Server}. - * - * @see Server#insertHandler(HandlerWrapper) - */ - public JettyServiceBuilder handlerWrapper(HandlerWrapper handlerWrapper) { - handlerWrappers.add(requireNonNull(handlerWrapper, "handlerWrapper")); - return this; - } - - /** - * Adds the specified {@link HttpConfiguration} to the Jetty {@link Server}. - * This method is a type-safe alias of {@link #bean(Object)}. - */ - public JettyServiceBuilder httpConfiguration(HttpConfiguration httpConfiguration) { - return bean(httpConfiguration); - } - - /** - * Sets the {@link RequestLog} of the Jetty {@link Server}. - * - * @see Server#setRequestLog(RequestLog) - */ - public JettyServiceBuilder requestLog(RequestLog requestLog) { - this.requestLog = requireNonNull(requestLog, "requestLog"); - return this; - } - - /** - * Sets the {@link SessionIdManager} of the Jetty {@link Server}. This method is a shortcut for: - *
{@code
-     * sessionIdManagerFactory(server -> sessionIdManager);
-     * }
- * - * @see Server#setSessionIdManager(SessionIdManager) - */ - public JettyServiceBuilder sessionIdManager(SessionIdManager sessionIdManager) { - requireNonNull(sessionIdManager, "sessionIdManager"); - return sessionIdManagerFactory(server -> sessionIdManager); - } - - /** - * Sets the factory that creates a new instance of {@link SessionIdManager} for the Jetty {@link Server}. - * - * @see Server#setSessionIdManager(SessionIdManager) - */ - public JettyServiceBuilder sessionIdManagerFactory( - Function sessionIdManagerFactory) { - requireNonNull(sessionIdManagerFactory, "sessionIdManagerFactory"); - this.sessionIdManagerFactory = sessionIdManagerFactory; - return this; - } - - /** - * Sets the graceful stop time of the {@link Server#stop()} in milliseconds. - * - * @see Server#setStopTimeout(long) - */ - public JettyServiceBuilder stopTimeoutMillis(long stopTimeoutMillis) { - this.stopTimeoutMillis = stopTimeoutMillis; - return this; - } - - /** - * Adds the specified event listener to the Jetty {@link Server}. - */ - public JettyServiceBuilder eventListener(Container.Listener eventListener) { - eventListeners.add(requireNonNull(eventListener, "eventListener")); - return this; - } - - /** - * Adds the specified life cycle listener to the Jetty {@link Server}. - */ - public JettyServiceBuilder lifeCycleListener(LifeCycle.Listener lifeCycleListener) { - lifeCycleListeners.add(requireNonNull(lifeCycleListener, "lifeCycleListener")); - return this; - } - - /** - * Sets whether Jetty has to perform reverse DNS lookup for the remote IP address on a TLS connection. - * By default, this flag is disabled because it is known to cause performance issues when the DNS server - * is not responsive enough. However, you might want to take the risk and enable it if you want the same - * behavior with Jetty 9.3 when mTLS is enabled. - * - * @see Jetty issue #1235 - * @see Jetty commit de7c146 - */ - public JettyServiceBuilder tlsReverseDnsLookup(boolean tlsReverseDnsLookup) { - this.tlsReverseDnsLookup = tlsReverseDnsLookup; - return this; - } - - /** - * Adds a {@link Consumer} that performs additional configuration operations against - * the Jetty {@link Server} created by a {@link JettyService}. - */ - public JettyServiceBuilder customizer(Consumer customizer) { - customizers.add(requireNonNull(customizer, "customizer")); - return this; - } - - /** - * Adds a {@link Consumer} that performs additional configuration operations against - * the Jetty {@link Server} created by a {@link JettyService}. - * - * @deprecated Use {@link #customizer(Consumer)}. - */ - @Deprecated - public JettyServiceBuilder configurator(Consumer configurator) { - return customizer(requireNonNull(configurator, "configurator")); - } - - /** - * Returns a newly-created {@link JettyService} based on the properties of this builder. - */ - public JettyService build() { - // Make a copy of the properties that's used in `serverFactory` so that any further modification of - // this builder doesn't affect the built server. - final Boolean dumpAfterStart = this.dumpAfterStart; - final Boolean dumpBeforeStop = this.dumpBeforeStop; - final Long stopTimeoutMillis = this.stopTimeoutMillis; - final Handler handler = this.handler; - final RequestLog requestLog = this.requestLog; - final Function sessionIdManagerFactory = - this.sessionIdManagerFactory; - final Map attrs = this.attrs.build(); - final List beans = this.beans.build(); - final List handlerWrappers = this.handlerWrappers.build(); - final List eventListeners = this.eventListeners.build(); - final List lifeCycleListeners = this.lifeCycleListeners.build(); - final List> customizers = this.customizers.build(); - - final Function serverFactory = blockingTaskExecutor -> { - final Server server = new Server(new ArmeriaThreadPool(blockingTaskExecutor)); - - if (dumpAfterStart != null) { - server.setDumpAfterStart(dumpAfterStart); - } - if (dumpBeforeStop != null) { - server.setDumpBeforeStop(dumpBeforeStop); - } - if (stopTimeoutMillis != null) { - server.setStopTimeout(stopTimeoutMillis); - } - - if (handler != null) { - server.setHandler(handler); - } - if (requestLog != null) { - server.setRequestLog(requestLog); - } - if (sessionIdManagerFactory != null) { - server.setSessionIdManager(sessionIdManagerFactory.apply(server)); - } - - handlerWrappers.forEach(server::insertHandler); - attrs.forEach(server::setAttribute); - beans.forEach(bean -> { - final Boolean managed = bean.isManaged(); - if (managed == null) { - server.addBean(bean.bean()); - } else { - server.addBean(bean.bean(), managed); - } - }); - - eventListeners.forEach(server::addEventListener); - lifeCycleListeners.forEach(server::addLifeCycleListener); - - customizers.forEach(c -> c.accept(server)); - - return server; - }; - - final Consumer postStopTask = server -> { - try { - JettyService.logger.info("Destroying an embedded Jetty: {}", server); - server.destroy(); - } catch (Exception e) { - JettyService.logger.warn("Failed to destroy an embedded Jetty: {}", server, e); - } - }; - - return new JettyService(hostname, tlsReverseDnsLookup, serverFactory, postStopTask); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .omitNullValues() - .add("hostname", hostname) - .add("dumpAfterStart", dumpAfterStart) - .add("dumpBeforeStop", dumpBeforeStop) - .add("stopTimeoutMillis", stopTimeoutMillis) - .add("handler", handler) - .add("requestLog", requestLog) - .add("sessionIdManagerFactory", sessionIdManagerFactory) - .add("attrs", attrs) - .add("beans", beans) - .add("handlerWrappers", handlerWrappers) - .add("eventListeners", eventListeners) - .add("lifeCycleListeners", lifeCycleListeners) - .add("tlsReverseDnsLookup", tlsReverseDnsLookup) - .add("customizers", customizers) - .toString(); - } - - static final class Bean { - - private final Object bean; - @Nullable - private final Boolean managed; - - Bean(Object bean, @Nullable Boolean managed) { - this.bean = requireNonNull(bean, "bean"); - this.managed = managed; - } - - Object bean() { - return bean; - } - - @Nullable - Boolean isManaged() { - return managed; - } - - @Override - public String toString() { - final String mode = managed != null ? managed ? "managed" : "unmanaged" - : "auto"; - return "(" + bean + ", " + mode + ')'; - } - } -} diff --git a/settings.gradle b/settings.gradle index d59d2f8e41f..f56e10a7684 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,7 +36,12 @@ includeWithFlags ':grpc-kotlin', 'java', 'publish', 'rel includeWithFlags ':grpc-protocol', 'java', 'publish', 'relocate' includeWithFlags ':graphql', 'java', 'publish', 'relocate' includeWithFlags ':graphql-protocol', 'java', 'publish', 'relocate' -includeWithFlags ':jetty9', 'java', 'publish', 'relocate' +includeWithFlags ':jetty9', 'java', 'publish', 'relocate', 'no_aggregation' +project(':jetty9').projectDir = file('jetty/jetty9') +includeWithFlags ':jetty10', 'java11', 'publish', 'relocate', 'no_aggregation' +project(':jetty10').projectDir = file('jetty/jetty10') +includeWithFlags ':jetty11', 'java11', 'publish', 'relocate' +project(':jetty11').projectDir = file('jetty/jetty11') includeWithFlags ':junit4', 'java', 'publish', 'relocate' includeWithFlags ':junit5', 'java', 'publish', 'relocate' includeWithFlags ':kafka', 'java', 'publish', 'relocate' @@ -141,6 +146,7 @@ includeWithFlags ':it:thrift0.9.1', 'java', 'relocate' includeWithFlags ':it:trace-context-leak', 'java', 'relocate' includeWithFlags ':it:websocket', 'java', 'relocate' includeWithFlags ':jetty9.3', 'java', 'relocate' +project(':jetty9.3').projectDir = file('jetty/jetty9.3') includeWithFlags ':testing-internal', 'java', 'relocate' includeWithFlags ':thrift0.12', 'java', 'relocate' project(':thrift0.12').projectDir = file('thrift/thrift0.12') @@ -183,6 +189,7 @@ includeWithFlags ':examples:proxy-server', 'java11' includeWithFlags ':examples:resilience4j-spring', 'java17' includeWithFlags ':examples:saml-service-provider', 'java11' includeWithFlags ':examples:server-sent-events', 'java11' +includeWithFlags ':examples:spring-boot-jetty', 'java17' includeWithFlags ':examples:spring-boot-minimal', 'java17' includeWithFlags ':examples:spring-boot-minimal-kotlin', 'java17', 'kotlin' includeWithFlags ':examples:spring-boot-tomcat', 'java17'