This is an automated email from the ASF dual-hosted git repository. cziegeler pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/felix-dev.git
The following commit(s) were added to refs/heads/master by this push: new a49d9f356b FELIX-6692 Add Jetty WebSocket support for jetty 11.x (#309) a49d9f356b is described below commit a49d9f356bac9a0ec87a32f5f9acff1716103ec4 Author: Eric Norman <enor...@apache.org> AuthorDate: Wed May 1 00:02:55 2024 -0700 FELIX-6692 Add Jetty WebSocket support for jetty 11.x (#309) * FELIX-6692 Add Jetty WebSocket support for jetty 11.x * FELIX-6692 rename jakarta websocket enable config for future expansion * FELIX-6692 add paxexam integration tests to verify the functionality * FELIX-6692 cleanup * FELIX-6692 merge changes from PR #310 Rename org.apache.felix.jetty.websocket.enable to org.apache.felix.jetty.ee9.websocket.enable Incorporate the changes for using maybeStoreWebSocketContainerAttributes * FELIX-6692 renamed for consistency --- .../http/base/internal/HttpServiceController.java | 9 + .../internal/whiteboard/WhiteboardManager.java | 30 +++ http/jetty/pom.xml | 143 +++++++++++++- .../jetty/internal/ConfigMetaTypeProvider.java | 12 ++ .../felix/http/jetty/internal/JettyConfig.java | 23 +++ .../felix/http/jetty/internal/JettyService.java | 83 +++++++++ .../http/jetty/it/AbstractJettyTestSupport.java | 185 ++++++++++++++++++ .../jetty/it/JakartaEE9SpecificWebsocketIT.java | 207 +++++++++++++++++++++ .../http/jetty/it/JettyEE9SpecificWebsocketIT.java | 207 +++++++++++++++++++++ .../jetty/it/MissingWebsocketDependenciesIT.java | 89 +++++++++ 10 files changed, 983 insertions(+), 5 deletions(-) diff --git a/http/base/src/main/java/org/apache/felix/http/base/internal/HttpServiceController.java b/http/base/src/main/java/org/apache/felix/http/base/internal/HttpServiceController.java index 570e9fe1ce..96439f5b1a 100644 --- a/http/base/src/main/java/org/apache/felix/http/base/internal/HttpServiceController.java +++ b/http/base/src/main/java/org/apache/felix/http/base/internal/HttpServiceController.java @@ -138,6 +138,15 @@ public final class HttpServiceController this.dispatcher.setWhiteboardManager(this.whiteboardManager); } + /** + * Stores an attribute in the to be created shared servlet context. + * @param key attribute key + * @param value attribute value + */ + public void setAttributeSharedServletContext(String key, Object value) { + this.whiteboardManager.setAttributeSharedServletContext(key, value); + } + /** * Stops the http and http whiteboard service. */ diff --git a/http/base/src/main/java/org/apache/felix/http/base/internal/whiteboard/WhiteboardManager.java b/http/base/src/main/java/org/apache/felix/http/base/internal/whiteboard/WhiteboardManager.java index f082122f7c..7d95499eb2 100644 --- a/http/base/src/main/java/org/apache/felix/http/base/internal/whiteboard/WhiteboardManager.java +++ b/http/base/src/main/java/org/apache/felix/http/base/internal/whiteboard/WhiteboardManager.java @@ -70,6 +70,7 @@ import org.apache.felix.http.base.internal.whiteboard.tracker.ResourceTracker; import org.apache.felix.http.base.internal.whiteboard.tracker.ServletContextHelperTracker; import org.apache.felix.http.base.internal.whiteboard.tracker.ServletTracker; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.osgi.framework.BundleContext; import org.osgi.framework.Constants; import org.osgi.framework.Filter; @@ -118,6 +119,7 @@ public final class WhiteboardManager private final FailureStateHandler failureStateHandler = new FailureStateHandler(); private volatile ServletContext webContext; + private volatile Map<String, Object> attributesForSharedContext = new HashMap<>(); /** * Create a new whiteboard http manager @@ -203,6 +205,7 @@ public final class WhiteboardManager this.contextMap.clear(); this.servicesMap.clear(); this.failureStateHandler.clear(); + this.attributesForSharedContext.clear(); this.registry.reset(); } @@ -366,6 +369,8 @@ public final class WhiteboardManager { handlerList.add(handler); Collections.sort(handlerList); + setAttributes(handler.getSharedContext()); + this.contextMap.put(info.getName(), handlerList); // check for deactivate @@ -402,6 +407,21 @@ public final class WhiteboardManager return false; } + /** + * Set the stored attributes on the shared servlet context. + * @param context the shared servlet context + */ + private void setAttributes(@Nullable ServletContext context) { + if (context != null) { + attributesForSharedContext.forEach((key, value) -> { + if (key != null && value != null) { + SystemLogger.LOGGER.info("Shared context found, setting stored attribute key: '{}', value: '{}'", key, value); + context.setAttribute(key, value); + } + }); + } + } + /** * Remove a servlet context helper * @@ -953,4 +973,14 @@ public final class WhiteboardManager { this.serviceRuntime.updateChangeCount(); } + + /** + * Stores an attribute in the to be created shared servlet context. + * @param key attribute key + * @param value attribute value + */ + public void setAttributeSharedServletContext(String key, Object value) { + SystemLogger.LOGGER.info("Storing attribute for shared servlet context. Key '{}', value: '{}'", key, value); + this.attributesForSharedContext.put(key, value); + } } diff --git a/http/jetty/pom.xml b/http/jetty/pom.xml index bb16c026a0..9a9fd003b2 100644 --- a/http/jetty/pom.xml +++ b/http/jetty/pom.xml @@ -44,6 +44,9 @@ <felix.java.version>11</felix.java.version> <jetty.version>11.0.20</jetty.version> <baseline.skip>true</baseline.skip> + <org.ops4j.pax.exam.version>4.13.3</org.ops4j.pax.exam.version> + <!-- To debug the pax process, override this with -D --> + <pax.vm.options>-Xmx512M</pax.vm.options> </properties> <build> @@ -70,7 +73,11 @@ // scan each of the artifacts to preserve the information found in any META-INF/services/* files project.artifacts.each() { artifact -> - if (artifact.getArtifactHandler().isAddedToClasspath() && !org.apache.maven.artifact.Artifact.SCOPE_TEST.equals( artifact.getScope() )) { + if (artifact.getArtifactHandler().isAddedToClasspath() && !org.apache.maven.artifact.Artifact.SCOPE_TEST.equals( artifact.getScope() ) + && !"org.eclipse.jetty.websocket".equals(artifact.getGroupId()) // skip the optional websocket artifacts + && !"jetty-annotations".equals(artifact.getArtifactId()) // skip the transitive artifacts from the optional websocket artifacts + && !"jetty-plus".equals(artifact.getArtifactId()) + && !"jetty-webapp".equals(artifact.getArtifactId())) { def jar; try { jar = new java.util.jar.JarFile(artifact.file) @@ -165,9 +172,15 @@ org.osgi.service.servlet.runtime, org.osgi.service.servlet.runtime.dto, org.osgi.service.servlet.whiteboard, - !org.eclipse.jetty, - !org.eclipse.jetty.version, - org.eclipse.jetty.*, + org.eclipse.jetty.alpn.server, + org.eclipse.jetty.http.*, + org.eclipse.jetty.http2.*, + org.eclipse.jetty.io.*, + org.eclipse.jetty.jmx.*, + org.eclipse.jetty.security.*, + org.eclipse.jetty.server.*, + org.eclipse.jetty.servlet.*, + org.eclipse.jetty.util.*, org.apache.felix.http.jetty, org.apache.felix.http.jakartawrappers, org.apache.felix.http.javaxwrappers @@ -320,6 +333,42 @@ </execution> </executions> </plugin> + + <plugin> + <artifactId>maven-surefire-plugin</artifactId> + <configuration> + <redirectTestOutputToFile>true</redirectTestOutputToFile> + </configuration> + </plugin> + <!-- plugins for paxexam integration tests --> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-failsafe-plugin</artifactId> + <executions> + <execution> + <id>integration-test</id> + <phase>integration-test</phase> + <goals> + <goal>integration-test</goal> + </goals> + </execution> + <execution> + <id>verify</id> + <phase>integration-test</phase> + <goals> + <goal>verify</goal> + </goals> + </execution> + </executions> + <configuration> + <redirectTestOutputToFile>true</redirectTestOutputToFile> + <systemPropertyVariables> + <jetty.version>${jetty.version}</jetty.version> + <bundle.filename>${basedir}/target/${project.build.finalName}.jar</bundle.filename> + <pax.vm.options>${pax.vm.options}</pax.vm.options> + </systemPropertyVariables> + </configuration> + </plugin> </plugins> </build> @@ -410,7 +459,19 @@ <artifactId>jetty-alpn-server</artifactId> <version>${jetty.version}</version> </dependency> - <dependency> + <dependency> + <groupId>org.eclipse.jetty.websocket</groupId> + <artifactId>websocket-jakarta-server</artifactId> + <version>${jetty.version}</version> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.eclipse.jetty.websocket</groupId> + <artifactId>websocket-jetty-server</artifactId> + <version>${jetty.version}</version> + <optional>true</optional> + </dependency> + <dependency> <groupId>org.osgi</groupId> <artifactId>org.osgi.service.servlet</artifactId> <version>2.0.0</version> @@ -467,5 +528,77 @@ <version>1.3.0</version> <scope>test</scope> </dependency> + + <!-- an OSGi framework --> + <dependency> + <groupId>org.apache.felix</groupId> + <artifactId>org.apache.felix.framework</artifactId> + <version>7.0.5</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>javax.inject</groupId> + <artifactId>javax.inject</artifactId> + <version>1</version> + <scope>test</scope> + </dependency> + + <!-- Pax Exam --> + <dependency> + <groupId>org.ops4j.pax.exam</groupId> + <artifactId>pax-exam</artifactId> + <version>${org.ops4j.pax.exam.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.ops4j.pax.exam</groupId> + <artifactId>pax-exam-cm</artifactId> + <version>${org.ops4j.pax.exam.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.ops4j.pax.exam</groupId> + <artifactId>pax-exam-container-forked</artifactId> + <version>${org.ops4j.pax.exam.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.ops4j.pax.exam</groupId> + <artifactId>pax-exam-junit4</artifactId> + <version>${org.ops4j.pax.exam.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.ops4j.pax.exam</groupId> + <artifactId>pax-exam-link-mvn</artifactId> + <version>${org.ops4j.pax.exam.version}</version> + <scope>test</scope> + </dependency> + + <dependency> + <groupId>org.eclipse.jetty</groupId> + <artifactId>jetty-client</artifactId> + <version>${jetty.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.eclipse.jetty.websocket</groupId> + <artifactId>websocket-jetty-client</artifactId> + <version>${jetty.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.awaitility</groupId> + <artifactId>awaitility</artifactId> + <version>4.2.1</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-simple</artifactId> + <version>2.0.13</version> + <scope>test</scope> + </dependency> + </dependencies> </project> diff --git a/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java b/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java index 9e50ed7673..89870aaa17 100644 --- a/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java +++ b/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java @@ -483,6 +483,18 @@ class ConfigMetaTypeProvider implements MetaTypeProvider "The format of the request log entries. Only relevant if 'Enable SLF4J Request Logging' is checked. Valid placeholders are described in https://www.eclipse.org/jetty/documentation/jetty-11/operations-guide/index.html#og-module-requestlog", CustomRequestLog.NCSA_FORMAT, bundle.getBundleContext().getProperty(JettyConfig.FELIX_HTTP_REQUEST_LOG_FORMAT))); + + adList.add(new AttributeDefinitionImpl(JettyConfig.FELIX_JAKARTA_EE9_WEBSOCKET_ENABLE, + "Enable Jakarta EE9 standard WebSocket support", + "Whether to enable jakarta EE9 standard WebSocket support. Default is false.", + false, + bundle.getBundleContext().getProperty(JettyConfig.FELIX_JAKARTA_EE9_WEBSOCKET_ENABLE))); + adList.add(new AttributeDefinitionImpl(JettyConfig.FELIX_JETTY_EE9_WEBSOCKET_ENABLE, + "Enable Jetty specific EE9 WebSocket support", + "Whether to enable jetty specific WebSocket support. Default is false.", + false, + bundle.getBundleContext().getProperty(JettyConfig.FELIX_JETTY_EE9_WEBSOCKET_ENABLE))); + return new ObjectClassDefinition() { diff --git a/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java b/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java index ef336ea61c..e8b07f72bd 100644 --- a/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java +++ b/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java @@ -268,6 +268,13 @@ public final class JettyConfig /** Felix specific property to specify the default protocol when negotiation fails */ public static final String FELIX_JETTY_ALPN_DEFAULT_PROTOCOL = "org.apache.felix.jetty.alpn.defaultProtocol"; + /** Felix specific property to control whether to enable the standard jakarta.websocket EE9 APIs provided by Jakarta WebSocket 2.0 */ + public static final String FELIX_JAKARTA_EE9_WEBSOCKET_ENABLE = "org.apache.felix.jakarta.ee9.websocket.enable"; + + /** Felix specific property to control whether to enable they Jetty-specific WebSocket APIs */ + public static final String FELIX_JETTY_EE9_WEBSOCKET_ENABLE = "org.apache.felix.jetty.ee9.websocket.enable"; + + private static String validateContextPath(String ctxPath) { // undefined, empty, or root context path @@ -674,6 +681,22 @@ public final class JettyConfig return getLongProperty(FELIX_JETTY_STOP_TIMEOUT, -1l); } + /** + * Returns <code>true</code> if jakarta EE9 websocket is configured to be used ( + * {@link #FELIX_JAKARTA_EE9_WEBSOCKET_ENABLE}) + */ + public boolean isUseJakartaEE9Websocket() { + return getBooleanProperty(FELIX_JAKARTA_EE9_WEBSOCKET_ENABLE, false); + } + + /** + * Returns <code>true</code> if jetty websocket is configured to be used ( + * {@link #FELIX_JETTY_WEBSOCKET_ENABLE}) + */ + public boolean isUseJettyEE9Websocket() { + return getBooleanProperty(FELIX_JETTY_EE9_WEBSOCKET_ENABLE, false); + } + public void reset() { update(null); diff --git a/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java b/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java index 4b7c32b784..d9e4c3768a 100644 --- a/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java +++ b/http/jetty/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java @@ -53,6 +53,7 @@ import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ThreadPool; +import org.eclipse.jetty.websocket.server.JettyWebSocketServerContainer; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.Constants; @@ -308,8 +309,18 @@ public final class JettyService this.server.setStopTimeout(this.config.getStopTimeout()); } + if (this.config.isUseJettyEE9Websocket()) { + maybeInitializeJettyEE9Websocket(context); + } + + if (this.config.isUseJakartaEE9Websocket()) { + maybeInitializeJakartaEE9Websocket(context); + } + this.server.start(); + maybeStoreWebSocketContainerAttributes(context); + // session id manager is only available after server is started context.getSessionHandler().getSessionIdManager().getSessionHouseKeeper().setIntervalSec( this.config.getLongProperty(JettyConfig.FELIX_JETTY_SESSION_SCAVENGING_INTERVAL, @@ -477,6 +488,78 @@ public final class JettyService return startConnector(connector); } + /** + * Initialize the jakarta EE9 websocket support for the servlet context handler. + * If the optional initializer class is not present then a warning will be logged. + * + * @param handler the sevlet context handler to initialize + */ + private void maybeInitializeJakartaEE9Websocket(ServletContextHandler handler) { + if (isClassNameVisible("org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer")) { + // Ensure that JavaxWebSocketServletContainerInitializer is initialized, + // to setup the ServerContainer for this web application context. + org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer.configure(handler, null); + } else { + SystemLogger.LOGGER.warn("Failed to initialize jakarta EE9 standard websocket support since the initializer class was not found. " + + "Check if the websocket-jakarta-server bundle is deployed."); + } + } + + /** + * Initialize the jetty EE9 websocket support for the servlet context handler. + * If the optional initializer class is not present then a warning will be logged. + * + * @param handler the sevlet context handler to initialize + */ + private void maybeInitializeJettyEE9Websocket(ServletContextHandler handler) { + if (isClassNameVisible("org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer")) { + // Ensure that JettyWebSocketServletContainerInitializer is initialized, + // to setup the JettyWebSocketServerContainer for this web application context. + org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer.configure(handler, null); + } else { + SystemLogger.LOGGER.warn("Failed to initialize jetty specific websocket support since the initializer class was not found. " + + "Check if the websocket-jetty-server bundle is deployed."); + } + } + + /** + * Based on the configuration, store the WebSocket container attributes for the shared servlet context. + * + * @param context the context + */ + private void maybeStoreWebSocketContainerAttributes(ServletContextHandler context) { + // when the server is started, retrieve the container attribute and + // set it on the shared servlet context once available + if (this.config.isUseJettyEE9Websocket() && + isClassNameVisible("org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer")) { + String attribute = JettyWebSocketServerContainer.JETTY_WEBSOCKET_CONTAINER_ATTRIBUTE; + this.controller.setAttributeSharedServletContext(attribute, context.getServletContext().getAttribute(attribute)); + } + if (this.config.isUseJakartaEE9Websocket() && + isClassNameVisible("org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer")) { + String attribute = org.eclipse.jetty.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer.ATTR_JAKARTA_SERVER_CONTAINER; + this.controller.setAttributeSharedServletContext(attribute, context.getServletContext().getAttribute(attribute)); + } + } + + /** + * Checks if an optional class name is visible to the bundle classloader + * + * @param className the class name to check + * @return true if the class is visible, false otherwise + */ + private boolean isClassNameVisible(String className) { + boolean visible; + try { + // check if the class is visible to our classloader + getClass().getClassLoader().loadClass(className); + visible = true; + } catch (ClassNotFoundException e) { + visible = false; + } + return visible; + } + private void configureSslContextFactory(final SslContextFactory.Server connector) { if (this.config.getKeystoreType() != null) diff --git a/http/jetty/src/test/java/org/apache/felix/http/jetty/it/AbstractJettyTestSupport.java b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/AbstractJettyTestSupport.java new file mode 100644 index 0000000000..617f712b39 --- /dev/null +++ b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/AbstractJettyTestSupport.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.ops4j.pax.exam.CoreOptions.bundle; +import static org.ops4j.pax.exam.CoreOptions.composite; +import static org.ops4j.pax.exam.CoreOptions.junitBundles; +import static org.ops4j.pax.exam.CoreOptions.keepCaches; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.CoreOptions.options; +import static org.ops4j.pax.exam.CoreOptions.systemProperty; +import static org.ops4j.pax.exam.CoreOptions.vmOption; +import static org.ops4j.pax.exam.CoreOptions.when; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.File; +import java.io.IOException; +import java.net.ServerSocket; +import java.util.UUID; + +import org.ops4j.pax.exam.Configuration; +import org.ops4j.pax.exam.CoreOptions; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.options.ModifiableCompositeOption; +import org.ops4j.pax.exam.options.OptionalCompositeOption; +import org.ops4j.pax.exam.options.SystemPropertyOption; +import org.ops4j.pax.exam.options.UrlProvisionOption; +import org.ops4j.pax.exam.options.extra.VMOption; +import org.ops4j.pax.exam.util.PathUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractJettyTestSupport { + + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + private final String workingDirectory = String.format("%s/target/paxexam/%s/%s", PathUtils.getBaseDir(), getClass().getSimpleName(), UUID.randomUUID()); + + /** + * Provides a random path for a working directory below Maven's build target directory. + * + * @return the absolute path for working directory + */ + protected String workingDirectory() { + return workingDirectory; + } + + @Configuration + public Option[] configuration() throws IOException { + final String vmOpt = System.getProperty("pax.vm.options"); + VMOption vmOption = null; + if (vmOpt != null && !vmOpt.isEmpty()) { + vmOption = new VMOption(vmOpt); + } + + final int httpPort = findFreePort(); + + return options( + composite( + when(vmOption != null).useOptions(vmOption), + failOnUnresolvedBundles(), + keepCaches(), + localMavenRepo(), + CoreOptions.workingDirectory(workingDirectory()), + optionalRemoteDebug(), + mavenBundle().groupId("org.apache.felix").artifactId("org.apache.felix.http.servlet-api").version("3.0.0"), + testBundle("bundle.filename"), + junitBundles(), + awaitility(), + + config(), + felixHttpConfig(httpPort) + ).add( + additionalOptions() + ) + ); + } + + public static ModifiableCompositeOption awaitility() { + return composite( + mavenBundle().groupId("org.awaitility").artifactId("awaitility").version("4.2.1"), + mavenBundle().groupId("org.hamcrest").artifactId("hamcrest").version("2.2") + ); + } + + public static ModifiableCompositeOption config() { + return composite( + mavenBundle().groupId("org.apache.felix").artifactId("org.apache.felix.configadmin").version("1.9.26") + ); + } + + protected Option felixHttpConfig(final int httpPort) { + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .asOption(); + } + + protected Option[] additionalOptions() throws IOException { // NOSONAR + return new Option[]{}; + } + + /** + * Finds a free local port. + * + * @return the free local port + */ + public static int findFreePort() { + try (ServerSocket serverSocket = new ServerSocket(0)) { + return serverSocket.getLocalPort(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Provides an option to set the System property {@code pax.exam.osgi.unresolved.fail} to {@code "true"}. + * + * @return the property option + */ + public static SystemPropertyOption failOnUnresolvedBundles() { + return systemProperty("pax.exam.osgi.unresolved.fail").value("true"); + } + + /** + * Reads the System property {@code maven.repo.local} and provides an option to set the System property {@code org.ops4j.pax.url.mvn.localRepository} when former is not empty. + * + * @return the property option + */ + public static OptionalCompositeOption localMavenRepo() { + final String localRepository = System.getProperty("maven.repo.local", ""); // PAXEXAM-543 + return when(!localRepository.isBlank()).useOptions( + systemProperty("org.ops4j.pax.url.mvn.localRepository").value(localRepository) + ); + } + + /** + * Reads the pathname of the test bundle from the given System property and provides a provisioning option. + * + * @param systemProperty the System property which contains the pathname of the test bundle + * @return the provisioning option + */ + public static UrlProvisionOption testBundle(final String systemProperty) { + final String pathname = System.getProperty(systemProperty); + final File file = new File(pathname); + return bundle(file.toURI().toString()); + } + + /** + * Optionally configure remote debugging on the port supplied by the "debugPort" + * system property. + */ + protected ModifiableCompositeOption optionalRemoteDebug() { + VMOption option = null; + String property = System.getProperty("debugPort"); + if (property != null) { + option = vmOption(String.format("-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=%s", property)); + } + return composite(option); + } + + public static ModifiableCompositeOption spifly() { + return composite( + mavenBundle().groupId("org.apache.aries.spifly").artifactId("org.apache.aries.spifly.dynamic.bundle").version("1.3.7"), + mavenBundle().groupId("org.ow2.asm").artifactId("asm-analysis").version("9.7"), + mavenBundle().groupId("org.ow2.asm").artifactId("asm-commons").version("9.7"), + mavenBundle().groupId("org.ow2.asm").artifactId("asm-tree").version("9.7"), + mavenBundle().groupId("org.ow2.asm").artifactId("asm-util").version("9.7"), + mavenBundle().groupId("org.ow2.asm").artifactId("asm").version("9.7") + ); + } +} diff --git a/http/jetty/src/test/java/org/apache/felix/http/jetty/it/JakartaEE9SpecificWebsocketIT.java b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/JakartaEE9SpecificWebsocketIT.java new file mode 100644 index 0000000000..70910ee970 --- /dev/null +++ b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/JakartaEE9SpecificWebsocketIT.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Hashtable; +import java.util.Map; + +import javax.inject.Inject; + +import org.awaitility.Awaitility; +import org.eclipse.jetty.websocket.jakarta.client.JakartaWebSocketClientContainerProvider; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.osgi.framework.BundleContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.servlet.whiteboard.HttpWhiteboardConstants; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.websocket.ClientEndpoint; +import jakarta.websocket.DeploymentException; +import jakarta.websocket.OnMessage; +import jakarta.websocket.OnOpen; +import jakarta.websocket.Session; +import jakarta.websocket.WebSocketContainer; +import jakarta.websocket.server.ServerContainer; +import jakarta.websocket.server.ServerEndpoint; + +/** + * + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class JakartaEE9SpecificWebsocketIT extends AbstractJettyTestSupport { + + @Inject + protected BundleContext bundleContext; + + @Override + protected Option[] additionalOptions() throws IOException { + String jettyVersion = System.getProperty("jetty.version", "11.0.20"); + return new Option[] { + spifly(), + + // bundles for the server side + mavenBundle().groupId("jakarta.websocket").artifactId("jakarta.websocket-api").version("2.0.0"), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-alpn-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-webapp").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-core-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-core-common").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-core-server").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jakarta-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jakarta-common").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jakarta-server").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-servlet").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-xml").version(jettyVersion) + }; + } + + @Override + protected Option felixHttpConfig(int httpPort) { + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .put("org.apache.felix.jakarta.ee9.websocket.enable", true) + .asOption(); + } + + @Test + public void testWebSocketConversation() throws Exception { + assertNotNull(bundleContext); + bundleContext.registerService(Servlet.class, new MyWebSocketInitServlet(), new Hashtable<>(Map.of( + HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/mywebsocket1" + ))); + + WebSocketContainer container = JakartaWebSocketClientContainerProvider.getContainer(null); + + // Create client side endpoint + MyClientWebSocket clientEndpoint = new MyClientWebSocket(); + + // Attempt Connect + Object value = bundleContext.getServiceReference(HttpService.class).getProperty("org.osgi.service.http.port"); + int httpPort = Integer.parseInt((String)value); + URI destUri = new URI(String.format("ws://localhost:%d/mywebsocket1", httpPort)); + try (Session session = container.connectToServer(clientEndpoint, destUri)) { + + // send a message from the client to the server + clientEndpoint.sendMessage("Hello WebSocket"); + + // wait for the async response from the server + Awaitility.await("waitForResponse") + .atMost(Duration.ofSeconds(30)) + .pollDelay(Duration.ofMillis(200)) + .until(() -> clientEndpoint.getLastMessage() != null); + assertEquals("Hello WebSocket", clientEndpoint.getLastMessage()); + } + } + + /** + * A servlet that declares the websocket during init + */ + private static final class MyWebSocketInitServlet extends HttpServlet { + private static final long serialVersionUID = -6893620059263229183L; + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + + // Retrieve the ServerContainer from the ServletContext attributes. + ServletContext servletContext = config.getServletContext(); + ServerContainer container = (ServerContainer)servletContext.getAttribute(ServerContainer.class.getName()); + + // Configure the ServerContainer. + container.setDefaultMaxTextMessageBufferSize(128 * 1024); + + // Simple registration of your WebSocket endpoints. + try { + container.addEndpoint(MyServerWebSocket.class); + } catch (DeploymentException e) { + throw new ServletException(e); + } + } + } + + /** + * WebSocket handler for the client side + */ + @ClientEndpoint + public static class MyClientWebSocket { + private Session session; + private String lastMessage; + + public String getLastMessage() { + return lastMessage; + } + + @OnOpen + public void onConnect(Session session) { + this.session = session; + } + + /** + * Send a message to the server side + * @param msg the message to send + */ + public void sendMessage(String msg) throws IOException { + this.session.getBasicRemote().sendText(msg); + } + + /** + * Receive a message from the server side + * @param msg the message + */ + @OnMessage + public void onMessage(String msg) { + lastMessage = msg; + } + } + + /** + * WebSocket handler for the server side + */ + @ServerEndpoint(value = "/mywebsocket1") + public static class MyServerWebSocket { + /** + * Receive message sent from the client + * + * @param session the session + * @param message the message + */ + @OnMessage + public void onText(Session session, String message) throws IOException { + // echo a response back to the client + session.getBasicRemote().sendText(message); + } + } + +} diff --git a/http/jetty/src/test/java/org/apache/felix/http/jetty/it/JettyEE9SpecificWebsocketIT.java b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/JettyEE9SpecificWebsocketIT.java new file mode 100644 index 0000000000..162c4b7ecd --- /dev/null +++ b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/JettyEE9SpecificWebsocketIT.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Hashtable; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import javax.inject.Inject; + +import org.awaitility.Awaitility; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.eclipse.jetty.websocket.server.JettyWebSocketServerContainer; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.osgi.framework.BundleContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.servlet.whiteboard.HttpWhiteboardConstants; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; + +/** + * + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class JettyEE9SpecificWebsocketIT extends AbstractJettyTestSupport { + + @Inject + protected BundleContext bundleContext; + + @Override + protected Option[] additionalOptions() throws IOException { + String jettyVersion = System.getProperty("jetty.version", "11.0.20"); + return new Option[] { + spifly(), + + // bundles for the server side + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-webapp").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-core-common").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-core-server").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jetty-api").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jetty-common").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jetty-server").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-servlet").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-xml").version(jettyVersion), + + // additional bundles for the client side + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-alpn-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-core-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.websocket").artifactId("websocket-jetty-client").version(jettyVersion) + }; + } + + @Override + protected Option felixHttpConfig(int httpPort) { + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .put("org.apache.felix.jetty.ee9.websocket.enable", true) + .asOption(); + } + + + @Test + public void testWebSocketConversation() throws Exception { + assertNotNull(bundleContext); + bundleContext.registerService(Servlet.class, new MyWebSocketInitServlet(), new Hashtable<>(Map.of( + HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/mywebsocket1" + ))); + + HttpClientTransportOverHTTP transport = new HttpClientTransportOverHTTP(); + HttpClient httpClient = new org.eclipse.jetty.client.HttpClient(transport); + WebSocketClient webSocketClient = new WebSocketClient(httpClient); + webSocketClient.start(); + + Object value = bundleContext.getServiceReference(HttpService.class).getProperty("org.osgi.service.http.port"); + int httpPort = Integer.parseInt((String)value); + URI destUri = new URI(String.format("ws://localhost:%d/mywebsocket1", httpPort)); + + MyClientWebSocket clientWebSocket = new MyClientWebSocket(); + ClientUpgradeRequest request = new ClientUpgradeRequest(); + CompletableFuture<Session> future = webSocketClient.connect(clientWebSocket, destUri, request); + Session session = future.get(); + assertNotNull(session); + + // send a message from the client to the server + clientWebSocket.sendMessage("Hello WebSocket"); + + // wait for the async response from the server + Awaitility.await("waitForResponse") + .atMost(Duration.ofSeconds(30)) + .pollDelay(Duration.ofMillis(200)) + .until(() -> clientWebSocket.getLastMessage() != null); + assertEquals("Hello WebSocket", clientWebSocket.getLastMessage()); + } + + /** + * A servlet that declares the websocket during init + */ + private static final class MyWebSocketInitServlet extends HttpServlet { + private static final long serialVersionUID = -6893620059263229183L; + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + + // Retrieve the JettyWebSocketServerContainer. + ServletContext servletContext = config.getServletContext(); + JettyWebSocketServerContainer container = JettyWebSocketServerContainer.getContainer(servletContext); + assertNotNull(container); + container.addMapping("/mywebsocket1", (upgradeRequest, upgradeResponse) -> new MyServerWebSocket()); + } + } + + /** + * WebSocket handler for the client side + */ + @WebSocket(maxTextMessageSize = 64 * 1024) + public static class MyClientWebSocket { + private Session session; + private String lastMessage; + + public String getLastMessage() { + return lastMessage; + } + + @OnWebSocketConnect + public void onConnect(Session session) { + this.session = session; + } + + /** + * Send a message to the server side + * @param msg the message to send + */ + public void sendMessage(String msg) { + this.session.getRemote().sendString(msg, WriteCallback.NOOP); + } + + /** + * Receive a message from the server side + * @param msg the message + */ + @OnWebSocketMessage + public void onMessage(String msg) { + lastMessage = msg; + } + } + + /** + * WebSocket handler for the server side + */ + @WebSocket(maxTextMessageSize = 64 * 1024) + public static class MyServerWebSocket { + /** + * Receive message sent from the client + * + * @param session the session + * @param message the message + */ + @OnWebSocketMessage + public void onText(Session session, String message) { + // echo a response back to the client + session.getRemote().sendString(message, WriteCallback.NOOP); + } + } + +} diff --git a/http/jetty/src/test/java/org/apache/felix/http/jetty/it/MissingWebsocketDependenciesIT.java b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/MissingWebsocketDependenciesIT.java new file mode 100644 index 0000000000..4527f85583 --- /dev/null +++ b/http/jetty/src/test/java/org/apache/felix/http/jetty/it/MissingWebsocketDependenciesIT.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.junit.Assert.assertTrue; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.time.Duration; +import java.util.stream.Stream; + +import javax.inject.Inject; + +import org.awaitility.Awaitility; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.osgi.framework.BundleContext; + +/** + * + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class MissingWebsocketDependenciesIT extends AbstractJettyTestSupport { + + @Inject + protected BundleContext bundleContext; + + @Override + protected Option felixHttpConfig(int httpPort) { + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .put("org.apache.felix.jetty.ee9.websocket.enable", true) + .put("org.apache.felix.jakarta.ee9.websocket.enable", true) + .asOption(); + } + + @Test + public void testMissingDepencencyWarningLogs() throws Exception { + // should have warnings in the log file output + File logFile = new File("target/failsafe-reports/org.apache.felix.http.jetty.it.MissingWebsocketDependenciesIT-output.txt"); + assertTrue(logFile.exists()); + + // wait for the log buffer to be written to the file + Awaitility.await("waitForLogs") + .atMost(Duration.ofSeconds(50)) + .pollDelay(Duration.ofMillis(200)) + .until(() -> containsString(logFile, "org.apache.felix.http.jetty[org.apache.felix.http]")); + + assertTrue(containsString(logFile, "org.apache.felix.http.jetty[org.apache.felix.http] : Failed to initialize jetty specific websocket " + + "support since the initializer class was not found. Check if the websocket-jetty-server bundle is deployed.")); + assertTrue(containsString(logFile, "org.apache.felix.http.jetty[org.apache.felix.http] : Failed to initialize jakarta EE9 standard websocket" + + " support since the initializer class was not found. Check if the websocket-jakarta-server bundle is deployed.")); + } + + /** + * Checks if the text is present in the file + * + * @param file the file to check + * @param expected the text to look for + * @return true if the text was found, false otherwise + */ + private boolean containsString(File file, String expected) throws IOException { + try (Stream<String> stream = Files.lines(file.toPath())) { + return stream.anyMatch(line -> line.contains(expected)); + } + } + +}