From 766b91554fcd4ecf388c6ebfe1f3392ca2c6bcde Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 17 May 2024 16:39:05 -0700 Subject: [PATCH 01/11] initial (working) mechanism for consuming Oracle Queues and sending out through ActiveMQ Artemis utilizes Apache Camel with the AQAPI connection factory in order to hook into Oracle Queues and route to Artemis. TODOs: - determine artemis connection/port - determine which oracle queues to subscribe to - determine clientid/durable subscriber name - register artemis queues with swagger UI and standardize naming artemis architecture docs: https://activemq.apache.org/components/artemis/documentation/2.4.0/architecture.html --- cwms-data-api/build.gradle | 14 +++- .../src/main/java/cwms/cda/ApiServlet.java | 68 +++++++++++++++++- docs/uml/queues/artemis_architecture.jpg | Bin 0 -> 62657 bytes .../uml/queues/artemis_camel_oracle_queue.png | Bin 0 -> 16694 bytes .../queues/artemis_camel_oracle_queue.puml | 11 +++ gradle/libs.versions.toml | 7 ++ 6 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 docs/uml/queues/artemis_architecture.jpg create mode 100644 docs/uml/queues/artemis_camel_oracle_queue.png create mode 100644 docs/uml/queues/artemis_camel_oracle_queue.puml diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index d0f2a5335..b9ca75426 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -13,7 +13,7 @@ configurations { } configurations.implementation { - exclude group: 'com.oracle.database.jdbc' + exclude group: 'com.oracle.database.jdbc', module: 'ojdbc' } dependencies { @@ -114,6 +114,18 @@ dependencies { implementation(libs.bundles.jackson) + implementation(libs.aqapi) + implementation(libs.jmscommon) + //For some reason the caffeine transitive dependency makes gradle angry + implementation(libs.activemq.artemis.server) { + exclude group: "com.github.ben-manes.caffeine", module: "caffeine" + } + implementation(libs.activemq.artemis.client) { + exclude group: "com.github.ben-manes.caffeine", module: "caffeine" + } + implementation(libs.camel.core) + implementation(libs.camel.jms) + testImplementation(libs.bundles.junit) testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.mockito.core) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index bbc2aa3c4..21bb41172 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -99,6 +99,7 @@ import cwms.cda.api.errors.NotFoundException; import cwms.cda.api.errors.RequiredQueryParameterException; import cwms.cda.data.dao.JooqDao; +import cwms.cda.datasource.DelegatingDataSource; import cwms.cda.formatters.Formats; import cwms.cda.formatters.FormattingException; import cwms.cda.formatters.UnsupportedFormatException; @@ -128,9 +129,12 @@ import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.SQLException; import java.time.DateTimeException; import java.util.ArrayList; import java.util.Arrays; @@ -140,6 +144,8 @@ import java.util.concurrent.TimeUnit; import java.util.jar.Manifest; import javax.annotation.Resource; +import javax.jms.ConnectionFactory; +import javax.jms.TopicConnectionFactory; import javax.management.ServiceNotFoundException; import javax.servlet.ServletConfig; import javax.servlet.ServletException; @@ -148,6 +154,17 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.sql.DataSource; + +import oracle.jdbc.driver.OracleConnection; +import oracle.jms.AQjmsFactory; +import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.ActiveMQServers; +import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.jms.JmsComponent; +import org.apache.camel.impl.DefaultCamelContext; import org.apache.http.entity.ContentType; import org.jetbrains.annotations.NotNull; import org.owasp.html.HtmlPolicyBuilder; @@ -237,7 +254,7 @@ public void init(ServletConfig config) throws ServletException { @SuppressWarnings({"java:S125","java:S2095"}) // closed in destroy handler @Override - public void init() { + public void init() throws ServletException { JavalinValidation.register(UnitSystem.class, UnitSystem::systemFor); JavalinValidation.register(JooqDao.DeleteMethod.class, Controllers::getDeleteMethod); @@ -375,6 +392,55 @@ public void init() { .routes(this::configureRoutes) .javalinServlet(); + setupQueuing(); + } + + private void setupQueuing() throws ServletException { + try { + //TODO: determine how the port is configured + String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616"; + //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection + //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it + CamelContext camelContext = new DefaultCamelContext(); + TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DelegatingDataSource(cwms) + { + @Override + public Connection getConnection() throws SQLException { + return super.getConnection().unwrap(OracleConnection.class); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return super.getConnection(username, password).unwrap(OracleConnection.class); + } + }, true); + camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); + ActiveMQServer server = ActiveMQServers.newActiveMQServer(new ConfigurationImpl() + .setPersistenceEnabled(false) + .addAcceptorConfiguration("default", activeMqUrl) + .setJournalDirectory("target/data/journal") + //Need to update to verify roles + .setSecurityEnabled(false) + .addAcceptorConfiguration("invm", "vm://0")); + ConnectionFactory artemisConnectionFactory = new ActiveMQJMSConnectionFactory("vm://0"); + camelContext.addComponent("artemis", JmsComponent.jmsComponent(artemisConnectionFactory)); + camelContext.addRoutes(new RouteBuilder() { + public void configure() { + //TODO: configure Oracle Queue name for office + //TODO: determine durable subscription name - should be unique to CDA instance? + //TODO: determine clientId - should be unique to CDA version? + from("oracleAQ:topic:CWMS_20.SWT_TS_STORED?durableSubscriptionName=CDA_SWT_TS_STORED&clientId=CDA") + .log("Received message from ActiveMQ.Queue : ${body}") + //TODO: define standard naming + //TODO: register artemis queue names with Swagger UI + .to("artemis:queue:ActiveMQ.Queue"); + } + }); + server.start(); + camelContext.start(); + } catch (Exception e) { + throw new ServletException("Unable to setup Queues", e); + } } private String obtainFullVersion(ServletConfig servletConfig) throws ServletException { diff --git a/docs/uml/queues/artemis_architecture.jpg b/docs/uml/queues/artemis_architecture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d2b9de476269b64c7114bc07f9c9acb404133ecb GIT binary patch literal 62657 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 literal 0 HcmV?d00001 diff --git a/docs/uml/queues/artemis_camel_oracle_queue.png b/docs/uml/queues/artemis_camel_oracle_queue.png new file mode 100644 index 0000000000000000000000000000000000000000..28d68b25bac0986ef66c5d9d1b61cfe95a6e22f3 GIT binary patch literal 16694 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 AC: Produce Message (AQAPI) +AC -> AS: Consume/Transform/Route Message +AS -> ACA: Deliver Message (JMS) +ACA --> AS: Acknowledge Receipt (JMS) +@enduml \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4ee28ff94..02aa27807 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,6 +74,13 @@ jackson-dataformat-csv = { module = "com.fasterxml.jackson.dataformat:jackson-da jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } jackson-dataformat-xml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } +aqapi = { module = "com.oracle.database.jdbc:aqapi", version = "19.3.0.0" } +jmscommon = { module = "com.oracle.database.jdbc:jmscommon", version = "19.3.0.0" } +activemq-artemis-server = { module = "org.apache.activemq:artemis-server", version = "2.19.1" } +activemq-artemis-client = { module = "org.apache.activemq:artemis-jms-client-all", version = "2.19.1" } +#apache camel 2.x is the last to support JDK 8 +camel-core = {module = 'org.apache.camel:camel-core', version ='2.25.4' } +camel-jms = {module = 'org.apache.camel:camel-jms', version ='2.25.4' } #compile compileOnly From 4ec5bc3eb387c5ebc6e7d197c644609465675298 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Mon, 20 May 2024 15:15:58 -0700 Subject: [PATCH 02/11] failed attempt to get messages over STOMP protocol using websockets --- cwms-data-api/build.gradle | 7 +++++-- .../src/main/java/cwms/cda/ApiServlet.java | 17 +++++++++++------ gradle/libs.versions.toml | 1 + 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index b9ca75426..66406f501 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -123,6 +123,9 @@ dependencies { implementation(libs.activemq.artemis.client) { exclude group: "com.github.ben-manes.caffeine", module: "caffeine" } + implementation(libs.activemq.artemis.stomp) { + exclude group: "com.github.ben-manes.caffeine", module: "caffeine" + } implementation(libs.camel.core) implementation(libs.camel.jms) @@ -258,14 +261,14 @@ task run(type: JavaExec) { } task integrationTests(type: Test) { - dependsOn test +// dependsOn test dependsOn generateConfig dependsOn war useJUnitPlatform() { includeTags "integration" } - shouldRunAfter test +// shouldRunAfter test classpath += configurations.baseLibs classpath += configurations.tomcatLibs // The before all extension will take care of these properties diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 21bb41172..e04cb040f 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -116,6 +116,7 @@ import io.javalin.core.util.Header; import io.javalin.core.validation.JavalinValidation; import io.javalin.http.BadRequestResponse; +import io.javalin.http.Context; import io.javalin.http.Handler; import io.javalin.http.JavalinServlet; import io.javalin.plugin.openapi.OpenApiOptions; @@ -138,6 +139,7 @@ import java.time.DateTimeException; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -157,7 +159,10 @@ import oracle.jdbc.driver.OracleConnection; import oracle.jms.AQjmsFactory; +import org.apache.activemq.artemis.api.core.TransportConfiguration; import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.remoting.impl.netty.NettyAcceptorFactory; +import org.apache.activemq.artemis.core.remoting.impl.netty.TransportConstants; import org.apache.activemq.artemis.core.server.ActiveMQServer; import org.apache.activemq.artemis.core.server.ActiveMQServers; import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; @@ -397,8 +402,6 @@ public void init() throws ServletException { private void setupQueuing() throws ServletException { try { - //TODO: determine how the port is configured - String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616"; //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it CamelContext camelContext = new DefaultCamelContext(); @@ -415,10 +418,12 @@ public Connection getConnection(String username, String password) throws SQLExce } }, true); camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); + //TODO: determine how the port is configured + String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616?protocols=STOMP,CORE"; ActiveMQServer server = ActiveMQServers.newActiveMQServer(new ConfigurationImpl() - .setPersistenceEnabled(false) - .addAcceptorConfiguration("default", activeMqUrl) - .setJournalDirectory("target/data/journal") + .addAcceptorConfiguration("tcp", activeMqUrl) + .setPersistenceEnabled(true) + .setJournalDirectory("build/data/journal") //Need to update to verify roles .setSecurityEnabled(false) .addAcceptorConfiguration("invm", "vm://0")); @@ -433,7 +438,7 @@ public void configure() { .log("Received message from ActiveMQ.Queue : ${body}") //TODO: define standard naming //TODO: register artemis queue names with Swagger UI - .to("artemis:queue:ActiveMQ.Queue"); + .to("artemis:topic:ActiveMQ.Queue"); } }); server.start(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02aa27807..fda528b5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,7 @@ aqapi = { module = "com.oracle.database.jdbc:aqapi", version = "19.3.0.0" } jmscommon = { module = "com.oracle.database.jdbc:jmscommon", version = "19.3.0.0" } activemq-artemis-server = { module = "org.apache.activemq:artemis-server", version = "2.19.1" } activemq-artemis-client = { module = "org.apache.activemq:artemis-jms-client-all", version = "2.19.1" } +activemq-artemis-stomp = { module = "org.apache.activemq:artemis-stomp-protocol", version = "2.19.1" } #apache camel 2.x is the last to support JDK 8 camel-core = {module = 'org.apache.camel:camel-core', version ='2.25.4' } camel-jms = {module = 'org.apache.camel:camel-jms', version ='2.25.4' } From 988347b2a3e43927672c8e358948662530a581da Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Wed, 12 Jun 2024 14:18:02 -0700 Subject: [PATCH 03/11] move to standalone context listener --- .../src/main/java/cwms/cda/ApiServlet.java | 8 -- .../CamelServletContextListener.java | 113 +++++++++++++++++ .../cda/api/messaging/DataSourceWrapper.java | 115 ++++++++++++++++++ .../queues/artemis_camel_oracle_queue.puml | 7 ++ 4 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/DataSourceWrapper.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index e04cb040f..fb4c6d10a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -25,16 +25,8 @@ package cwms.cda; import static cwms.cda.api.Controllers.NAME; -import cwms.cda.api.DownstreamLocationsGetController; -import cwms.cda.api.LookupTypeController; -import cwms.cda.api.StreamController; -import cwms.cda.api.StreamLocationController; -import cwms.cda.api.StreamReachController; -import cwms.cda.api.UpstreamLocationsGetController; import static io.javalin.apibuilder.ApiBuilder.crud; -import static io.javalin.apibuilder.ApiBuilder.delete; import static io.javalin.apibuilder.ApiBuilder.get; -import static io.javalin.apibuilder.ApiBuilder.post; import static io.javalin.apibuilder.ApiBuilder.prefixPath; import static io.javalin.apibuilder.ApiBuilder.staticInstance; import static java.lang.String.format; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java new file mode 100644 index 000000000..81815709a --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java @@ -0,0 +1,113 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.messaging; + +import cwms.cda.datasource.DelegatingDataSource; +import oracle.jdbc.driver.OracleConnection; +import oracle.jms.AQjmsFactory; +import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.ActiveMQServers; +import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.jms.JmsComponent; +import org.apache.camel.impl.DefaultCamelContext; + +import javax.annotation.Resource; +import javax.jms.ConnectionFactory; +import javax.jms.TopicConnectionFactory; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import javax.servlet.ServletException; +import javax.sql.DataSource; +import java.net.InetAddress; +import java.sql.Connection; +import java.sql.SQLException; + +public final class CamelServletContextListener implements ServletContextListener { + + @Resource(name = "jdbc/CWMS3") + DataSource cwms; + private DefaultCamelContext camelContext; + + @Override + public void contextInitialized(ServletContextEvent servletContextEvent) { + try { + //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection + //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it + CamelContext camelContext = new DefaultCamelContext(); + TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DelegatingDataSource(cwms) + { + @Override + public Connection getConnection() throws SQLException { + return super.getConnection().unwrap(OracleConnection.class); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return super.getConnection(username, password).unwrap(OracleConnection.class); + } + }, true); + camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); + //TODO: determine how the port is configured + String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616"; + ActiveMQServer server = ActiveMQServers.newActiveMQServer(new ConfigurationImpl() + .addAcceptorConfiguration("tcp", activeMqUrl) + .setPersistenceEnabled(true) + .setJournalDirectory("build/data/journal") + //Need to update to verify roles + .setSecurityEnabled(false) + .addAcceptorConfiguration("invm", "vm://0")); + ConnectionFactory artemisConnectionFactory = new ActiveMQJMSConnectionFactory("vm://0"); + camelContext.addComponent("artemis", JmsComponent.jmsComponent(artemisConnectionFactory)); + camelContext.addRoutes(new RouteBuilder() { + public void configure() { + //TODO: configure Oracle Queue name for office + //TODO: determine durable subscription name - should be unique to CDA instance? + //TODO: determine clientId - should be unique to CDA version? + from("oracleAQ:topic:CWMS_20.SWT_TS_STORED?durableSubscriptionName=CDA_SWT_TS_STORED&clientId=CDA") + .log("Received message from ActiveMQ.Queue : ${body}") + //TODO: define standard naming + //TODO: register artemis queue names with Swagger UI + .to("artemis:topic:ActiveMQ.Queue"); + } + }); + server.start(); + camelContext.start(); + } catch (Exception e) { + throw new IllegalStateException("Unable to setup Queues", e); + } + } + + @Override + public void contextDestroyed(ServletContextEvent servletContextEvent) { + try { + camelContext.stop(); + } catch (Exception e) { + throw new IllegalStateException("Unable to stop Camel context during servlet shutdown"); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/DataSourceWrapper.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/DataSourceWrapper.java new file mode 100644 index 000000000..58ff95cb7 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/DataSourceWrapper.java @@ -0,0 +1,115 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.messaging; + +import oracle.jdbc.driver.OracleConnection; + +import javax.sql.DataSource; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.logging.Logger; + + +/** + * This class is a wrapper around a DataSource that delegates all calls to the + * wrapped DataSource. It is intended to be extended by classes that need to + * override DataSource methods. + */ +public class DataSourceWrapper implements DataSource { + + + private DataSource delegate; + + /** + * Create a new DelegatingDataSource. + * @param delegate the target DataSource + */ + public DataSourceWrapper(DataSource delegate) { + //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection + //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it + this.delegate = delegate; + } + + /** + * Return the target DataSource that this DataSource should delegate to. + */ + + public DataSource getDelegate() { + return this.delegate; + } + + @Override + public PrintWriter getLogWriter() throws SQLException { + return getDelegate().getLogWriter(); + } + + @Override + public void setLogWriter(PrintWriter out) throws SQLException { + getDelegate().setLogWriter(out); + } + + @Override + public int getLoginTimeout() throws SQLException { + return getDelegate().getLoginTimeout(); + } + + @Override + public void setLoginTimeout(int seconds) throws SQLException { + getDelegate().setLoginTimeout(seconds); + } + + + + @Override + @SuppressWarnings("unchecked") + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return (T) this; + } + return getDelegate().unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return (iface.isInstance(this) || getDelegate().isWrapperFor(iface)); + } + + + @Override + public Logger getParentLogger() { + return Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); + } + + @Override + public Connection getConnection() throws SQLException { + return getDelegate().getConnection().unwrap(OracleConnection.class); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return getDelegate().getConnection(username, password).unwrap(OracleConnection.class); + } +} \ No newline at end of file diff --git a/docs/uml/queues/artemis_camel_oracle_queue.puml b/docs/uml/queues/artemis_camel_oracle_queue.puml index 836f93927..6dfef1b99 100644 --- a/docs/uml/queues/artemis_camel_oracle_queue.puml +++ b/docs/uml/queues/artemis_camel_oracle_queue.puml @@ -5,7 +5,14 @@ participant "Artemis Server" as AS participant "Artemis Client API" as ACA OQ -> AC: Produce Message (AQAPI) +activate OQ +activate AC AC -> AS: Consume/Transform/Route Message +deactivate OQ +activate AS AS -> ACA: Deliver Message (JMS) +deactivate AC +activate ACA ACA --> AS: Acknowledge Receipt (JMS) +deactivate ACA @enduml \ No newline at end of file From 713cac3c67a4de9860fafad82f0ab4f903fb7f38 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Thu, 13 Jun 2024 10:17:57 -0700 Subject: [PATCH 04/11] Update CamelServletContextListener.java and add node.js STOMP websocket test. Reworked CamelServletContextListener.java to switch servlet annotation, and revise message connections and endpoints. Added a new package.json file for websocket-testing and a processor for message-to-json processing. Created a new javascript file for testing STOMP over websockets using node.js. This commit lays the groundwork for more effective messaging and debugging. --- .../CamelServletContextListener.java | 31 +++------- .../messaging/MapMessageToJsonProcessor.java | 61 +++++++++++++++++++ websocket-testing/package.json | 17 ++++++ .../src/main/javascript/stomp-ws-testing.js | 44 +++++++++++++ 4 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/MapMessageToJsonProcessor.java create mode 100644 websocket-testing/package.json create mode 100644 websocket-testing/src/main/javascript/stomp-ws-testing.js diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java index 81815709a..642157d72 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java @@ -24,8 +24,6 @@ package cwms.cda.api.messaging; -import cwms.cda.datasource.DelegatingDataSource; -import oracle.jdbc.driver.OracleConnection; import oracle.jms.AQjmsFactory; import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; import org.apache.activemq.artemis.core.server.ActiveMQServer; @@ -41,12 +39,11 @@ import javax.jms.TopicConnectionFactory; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; -import javax.servlet.ServletException; +import javax.servlet.annotation.WebListener; import javax.sql.DataSource; import java.net.InetAddress; -import java.sql.Connection; -import java.sql.SQLException; +@WebListener public final class CamelServletContextListener implements ServletContextListener { @Resource(name = "jdbc/CWMS3") @@ -58,26 +55,15 @@ public void contextInitialized(ServletContextEvent servletContextEvent) { try { //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it - CamelContext camelContext = new DefaultCamelContext(); - TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DelegatingDataSource(cwms) - { - @Override - public Connection getConnection() throws SQLException { - return super.getConnection().unwrap(OracleConnection.class); - } - - @Override - public Connection getConnection(String username, String password) throws SQLException { - return super.getConnection(username, password).unwrap(OracleConnection.class); - } - }, true); + camelContext = new DefaultCamelContext(); + TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DataSourceWrapper(cwms), true); camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); //TODO: determine how the port is configured - String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616"; + String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616?protocols=STOMP&webSocketEncoderType=text"; ActiveMQServer server = ActiveMQServers.newActiveMQServer(new ConfigurationImpl() .addAcceptorConfiguration("tcp", activeMqUrl) - .setPersistenceEnabled(true) - .setJournalDirectory("build/data/journal") + .setPersistenceEnabled(false) +// .setJournalDirectory("build/data/journal") //Need to update to verify roles .setSecurityEnabled(false) .addAcceptorConfiguration("invm", "vm://0")); @@ -90,9 +76,10 @@ public void configure() { //TODO: determine clientId - should be unique to CDA version? from("oracleAQ:topic:CWMS_20.SWT_TS_STORED?durableSubscriptionName=CDA_SWT_TS_STORED&clientId=CDA") .log("Received message from ActiveMQ.Queue : ${body}") + .process(new MapMessageToJsonProcessor(camelContext)) //TODO: define standard naming //TODO: register artemis queue names with Swagger UI - .to("artemis:topic:ActiveMQ.Queue"); + .to("artemis:topic:SWT_TS_STORED"); } }); server.start(); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/MapMessageToJsonProcessor.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/MapMessageToJsonProcessor.java new file mode 100644 index 000000000..cf3e0698a --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/MapMessageToJsonProcessor.java @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.messaging; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.Processor; +import org.apache.camel.component.jms.JmsMessage; + +import javax.jms.MapMessage; +import java.util.Map; + +final class MapMessageToJsonProcessor implements Processor { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final CamelContext context; + + MapMessageToJsonProcessor(CamelContext context) { + this.context = context; + } + + @SuppressWarnings("unchecked") + @Override + public void process(Exchange exchange) throws Exception { + Message inMessage = exchange.getIn(); + //If we use types other than MapMessage or TextMessage, we'd need to handle here + if (((JmsMessage) inMessage).getJmsMessage() instanceof MapMessage) { + Map map = inMessage.getBody(Map.class); + String payload = null; + + if (map != null) { + payload = OBJECT_MAPPER.writeValueAsString(map); + } + inMessage.setBody(payload); + inMessage.setHeader(Exchange.CONTENT_TYPE, "application/json"); + } + } +} diff --git a/websocket-testing/package.json b/websocket-testing/package.json new file mode 100644 index 000000000..4e649c23b --- /dev/null +++ b/websocket-testing/package.json @@ -0,0 +1,17 @@ +{ + "name": "websocket-testing", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "node ./src/main/javascript/stomp-ws-testing.js" + }, + "type": "module", + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@stomp/stompjs": "^7.0.0", + "ws": "^8.17.0" + } +} diff --git a/websocket-testing/src/main/javascript/stomp-ws-testing.js b/websocket-testing/src/main/javascript/stomp-ws-testing.js new file mode 100644 index 000000000..73b32bc11 --- /dev/null +++ b/websocket-testing/src/main/javascript/stomp-ws-testing.js @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import {Client} from '@stomp/stompjs'; + +import {WebSocket} from 'ws'; + +Object.assign(global, {WebSocket}); +const client = new Client({ + logRawCommunication: true, + brokerURL: 'ws://tacocat:61616/topic', connectionTimeout: 1000, onConnect: () => { + console.log("Connected") + client.subscribe('SWT_TS_STORED', message => { + console.log(`Received: ${message.body}`); + message.ack(); + }, {ack: 'client'}); + }, onStompError: (frame) => { + console.log('Broker reported error: ' + frame.headers['message']); + console.log('Additional details: ' + frame.body); + } +}); + +client.activate(); \ No newline at end of file From 04fdbdbb48eebed8272e269f35425259a9bddbeb Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Fri, 14 Jun 2024 08:02:34 -0700 Subject: [PATCH 05/11] update to use a broker.xml configuration --- .../CamelServletContextListener.java | 28 ++++++++----- .../src/test/resources/tomcat/conf/broker.xml | 39 +++++++++++++++++++ .../src/main/javascript/stomp-ws-testing.js | 2 +- 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 cwms-data-api/src/test/resources/tomcat/conf/broker.xml diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java index 642157d72..576cebaa9 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java @@ -24,8 +24,12 @@ package cwms.cda.api.messaging; +import hec.io.FilePath; +import hec.io.HecFileImpl; +import hec.util.XMLUtilities; import oracle.jms.AQjmsFactory; import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.config.impl.FileConfiguration; import org.apache.activemq.artemis.core.server.ActiveMQServer; import org.apache.activemq.artemis.core.server.ActiveMQServers; import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; @@ -33,6 +37,8 @@ import org.apache.camel.builder.RouteBuilder; import org.apache.camel.component.jms.JmsComponent; import org.apache.camel.impl.DefaultCamelContext; +import org.w3c.dom.Document; +import org.w3c.dom.Element; import javax.annotation.Resource; import javax.jms.ConnectionFactory; @@ -41,6 +47,9 @@ import javax.servlet.ServletContextListener; import javax.servlet.annotation.WebListener; import javax.sql.DataSource; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.File; import java.net.InetAddress; @WebListener @@ -58,15 +67,16 @@ public void contextInitialized(ServletContextEvent servletContextEvent) { camelContext = new DefaultCamelContext(); TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DataSourceWrapper(cwms), true); camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); - //TODO: determine how the port is configured - String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616?protocols=STOMP&webSocketEncoderType=text"; - ActiveMQServer server = ActiveMQServers.newActiveMQServer(new ConfigurationImpl() - .addAcceptorConfiguration("tcp", activeMqUrl) - .setPersistenceEnabled(false) -// .setJournalDirectory("build/data/journal") - //Need to update to verify roles - .setSecurityEnabled(false) - .addAcceptorConfiguration("invm", "vm://0")); + File brokerXmlFile = new File("src/test/resources/tomcat/conf/broker.xml").getAbsoluteFile(); + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + dbFactory.setNamespaceAware(true); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(brokerXmlFile); + doc.getDocumentElement().normalize(); + Element rootElement = doc.getDocumentElement(); + FileConfiguration configuration = new FileConfiguration(); + configuration.parse(rootElement, brokerXmlFile.toURI().toURL()); + ActiveMQServer server = ActiveMQServers.newActiveMQServer(configuration); ConnectionFactory artemisConnectionFactory = new ActiveMQJMSConnectionFactory("vm://0"); camelContext.addComponent("artemis", JmsComponent.jmsComponent(artemisConnectionFactory)); camelContext.addRoutes(new RouteBuilder() { diff --git a/cwms-data-api/src/test/resources/tomcat/conf/broker.xml b/cwms-data-api/src/test/resources/tomcat/conf/broker.xml new file mode 100644 index 000000000..f1d08feaa --- /dev/null +++ b/cwms-data-api/src/test/resources/tomcat/conf/broker.xml @@ -0,0 +1,39 @@ + + + + + + + ActiveMQServer + false + false + + tcp://localhost:61616?httpEnabled=true + vm://0 + + + \ No newline at end of file diff --git a/websocket-testing/src/main/javascript/stomp-ws-testing.js b/websocket-testing/src/main/javascript/stomp-ws-testing.js index 73b32bc11..ead88624d 100644 --- a/websocket-testing/src/main/javascript/stomp-ws-testing.js +++ b/websocket-testing/src/main/javascript/stomp-ws-testing.js @@ -29,7 +29,7 @@ import {WebSocket} from 'ws'; Object.assign(global, {WebSocket}); const client = new Client({ logRawCommunication: true, - brokerURL: 'ws://tacocat:61616/topic', connectionTimeout: 1000, onConnect: () => { + brokerURL: 'ws://localhost:61616/topic', connectionTimeout: 1000, onConnect: () => { console.log("Connected") client.subscribe('SWT_TS_STORED', message => { console.log(`Received: ${message.body}`); From 496d69b44629b433e6dc0168fad162467901b381 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Tue, 16 Jul 2024 09:54:47 -0700 Subject: [PATCH 06/11] remove extra routing from apiservlet --- .../src/main/java/cwms/cda/ApiServlet.java | 78 ++----------------- 1 file changed, 8 insertions(+), 70 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index fb4c6d10a..cc4ab6d05 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -26,7 +26,9 @@ import static cwms.cda.api.Controllers.NAME; import static io.javalin.apibuilder.ApiBuilder.crud; +import static io.javalin.apibuilder.ApiBuilder.delete; import static io.javalin.apibuilder.ApiBuilder.get; +import static io.javalin.apibuilder.ApiBuilder.post; import static io.javalin.apibuilder.ApiBuilder.prefixPath; import static io.javalin.apibuilder.ApiBuilder.staticInstance; import static java.lang.String.format; @@ -46,6 +48,7 @@ import cwms.cda.api.ClobController; import cwms.cda.api.Controllers; import cwms.cda.api.CountyController; +import cwms.cda.api.DownstreamLocationsGetController; import cwms.cda.api.EmbankmentController; import cwms.cda.api.ForecastFileController; import cwms.cda.api.ForecastInstanceController; @@ -55,6 +58,7 @@ import cwms.cda.api.LocationCategoryController; import cwms.cda.api.LocationController; import cwms.cda.api.LocationGroupController; +import cwms.cda.api.LookupTypeController; import cwms.cda.api.OfficeController; import cwms.cda.api.ParametersController; import cwms.cda.api.PoolController; @@ -67,6 +71,9 @@ import cwms.cda.api.SpecifiedLevelController; import cwms.cda.api.StandardTextController; import cwms.cda.api.StateController; +import cwms.cda.api.StreamController; +import cwms.cda.api.StreamLocationController; +import cwms.cda.api.StreamReachController; import cwms.cda.api.TextTimeSeriesController; import cwms.cda.api.TextTimeSeriesValueController; import cwms.cda.api.TimeSeriesCategoryController; @@ -80,6 +87,7 @@ import cwms.cda.api.TurbineChangesPostController; import cwms.cda.api.TurbineController; import cwms.cda.api.UnitsController; +import cwms.cda.api.UpstreamLocationsGetController; import cwms.cda.api.auth.ApiKeyController; import cwms.cda.api.enums.UnitSystem; import cwms.cda.api.errors.AlreadyExists; @@ -91,7 +99,6 @@ import cwms.cda.api.errors.NotFoundException; import cwms.cda.api.errors.RequiredQueryParameterException; import cwms.cda.data.dao.JooqDao; -import cwms.cda.datasource.DelegatingDataSource; import cwms.cda.formatters.Formats; import cwms.cda.formatters.FormattingException; import cwms.cda.formatters.UnsupportedFormatException; @@ -108,7 +115,6 @@ import io.javalin.core.util.Header; import io.javalin.core.validation.JavalinValidation; import io.javalin.http.BadRequestResponse; -import io.javalin.http.Context; import io.javalin.http.Handler; import io.javalin.http.JavalinServlet; import io.javalin.plugin.openapi.OpenApiOptions; @@ -122,24 +128,18 @@ import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; -import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.sql.Connection; -import java.sql.SQLException; import java.time.DateTimeException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.jar.Manifest; import javax.annotation.Resource; -import javax.jms.ConnectionFactory; -import javax.jms.TopicConnectionFactory; import javax.management.ServiceNotFoundException; import javax.servlet.ServletConfig; import javax.servlet.ServletException; @@ -149,19 +149,6 @@ import javax.servlet.http.HttpServletResponse; import javax.sql.DataSource; -import oracle.jdbc.driver.OracleConnection; -import oracle.jms.AQjmsFactory; -import org.apache.activemq.artemis.api.core.TransportConfiguration; -import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; -import org.apache.activemq.artemis.core.remoting.impl.netty.NettyAcceptorFactory; -import org.apache.activemq.artemis.core.remoting.impl.netty.TransportConstants; -import org.apache.activemq.artemis.core.server.ActiveMQServer; -import org.apache.activemq.artemis.core.server.ActiveMQServers; -import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; -import org.apache.camel.CamelContext; -import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.component.jms.JmsComponent; -import org.apache.camel.impl.DefaultCamelContext; import org.apache.http.entity.ContentType; import org.jetbrains.annotations.NotNull; import org.owasp.html.HtmlPolicyBuilder; @@ -389,55 +376,6 @@ public void init() throws ServletException { .routes(this::configureRoutes) .javalinServlet(); - setupQueuing(); - } - - private void setupQueuing() throws ServletException { - try { - //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection - //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it - CamelContext camelContext = new DefaultCamelContext(); - TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DelegatingDataSource(cwms) - { - @Override - public Connection getConnection() throws SQLException { - return super.getConnection().unwrap(OracleConnection.class); - } - - @Override - public Connection getConnection(String username, String password) throws SQLException { - return super.getConnection(username, password).unwrap(OracleConnection.class); - } - }, true); - camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); - //TODO: determine how the port is configured - String activeMqUrl = "tcp://" + InetAddress.getLocalHost().getHostName() + ":61616?protocols=STOMP,CORE"; - ActiveMQServer server = ActiveMQServers.newActiveMQServer(new ConfigurationImpl() - .addAcceptorConfiguration("tcp", activeMqUrl) - .setPersistenceEnabled(true) - .setJournalDirectory("build/data/journal") - //Need to update to verify roles - .setSecurityEnabled(false) - .addAcceptorConfiguration("invm", "vm://0")); - ConnectionFactory artemisConnectionFactory = new ActiveMQJMSConnectionFactory("vm://0"); - camelContext.addComponent("artemis", JmsComponent.jmsComponent(artemisConnectionFactory)); - camelContext.addRoutes(new RouteBuilder() { - public void configure() { - //TODO: configure Oracle Queue name for office - //TODO: determine durable subscription name - should be unique to CDA instance? - //TODO: determine clientId - should be unique to CDA version? - from("oracleAQ:topic:CWMS_20.SWT_TS_STORED?durableSubscriptionName=CDA_SWT_TS_STORED&clientId=CDA") - .log("Received message from ActiveMQ.Queue : ${body}") - //TODO: define standard naming - //TODO: register artemis queue names with Swagger UI - .to("artemis:topic:ActiveMQ.Queue"); - } - }); - server.start(); - camelContext.start(); - } catch (Exception e) { - throw new ServletException("Unable to setup Queues", e); - } } private String obtainFullVersion(ServletConfig servletConfig) throws ServletException { From 7e0cc56414a4634b7a0f185c03c33d213537425b Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Wed, 17 Jul 2024 15:00:02 -0700 Subject: [PATCH 07/11] add dynamic handling of camel routes groups routes for office as well as individual routes add basic security mechanism that requires client to specify username and apikey for the password create a specific cda apikey for authenticating within the vm --- cwms-data-api/build.gradle | 4 +- .../src/main/java/cwms/cda/ApiServlet.java | 13 +- .../api/messaging/ArtemisSecurityManager.java | 83 ++++++ .../cwms/cda/api/messaging/CamelRouter.java | 242 +++++++++++++++ .../CamelServletContextListener.java | 110 ------- .../cda/api/messaging/CdaTopicHandler.java | 169 +++++++++++ .../cda/data/dto/messaging/CdaTopics.java | 83 ++++++ .../cda/data/dto/messaging/cda_topics.json | 275 ++++++++++++++++++ .../src/test/resources/tomcat/conf/broker.xml | 1 - .../src/main/javascript/stomp-ws-testing.js | 12 +- 10 files changed, 875 insertions(+), 117 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/ArtemisSecurityManager.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelRouter.java delete mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/messaging/CdaTopics.java create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/messaging/cda_topics.json diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index 66406f501..4604f76b1 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -261,14 +261,14 @@ task run(type: JavaExec) { } task integrationTests(type: Test) { -// dependsOn test + dependsOn test dependsOn generateConfig dependsOn war useJUnitPlatform() { includeTags "integration" } -// shouldRunAfter test + shouldRunAfter test classpath += configurations.baseLibs classpath += configurations.tomcatLibs // The before all extension will take care of these properties diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index cc4ab6d05..dd44db5b0 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -98,6 +98,7 @@ import cwms.cda.api.errors.JsonFieldsException; import cwms.cda.api.errors.NotFoundException; import cwms.cda.api.errors.RequiredQueryParameterException; +import cwms.cda.api.messaging.CdaTopicHandler; import cwms.cda.data.dao.JooqDao; import cwms.cda.formatters.Formats; import cwms.cda.formatters.FormattingException; @@ -187,7 +188,8 @@ "/projects/*", "/properties/*", "/lookup-types/*", - "/embankments/*" + "/embankments/*", + "/cda-topics" }) public class ApiServlet extends HttpServlet { @@ -219,12 +221,13 @@ public class ApiServlet extends HttpServlet { @Resource(name = "jdbc/CWMS3") DataSource cwms; - + private CdaTopicHandler cdaTopicHandler; @Override public void destroy() { javalin.destroy(); + cdaTopicHandler.shutdown(); } @Override @@ -514,6 +517,12 @@ protected void configureRoutes() { new PropertyController(metrics), requiredRoles,1, TimeUnit.DAYS); cdaCrudCache(format("/lookup-types/{%s}", Controllers.NAME), new LookupTypeController(metrics), requiredRoles,1, TimeUnit.DAYS); + if(true || Boolean.getBoolean("cwms.data.api.messaging.enabled")) { + //TODO: setup separate data source for persistent connections to Oracle AQ + cdaTopicHandler = new CdaTopicHandler(cwms, metrics); + get("/cda-topics", cdaTopicHandler); + addCacheControl("/cda-topics", 1, TimeUnit.DAYS); + } } /** diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/ArtemisSecurityManager.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/ArtemisSecurityManager.java new file mode 100644 index 000000000..cb762a35e --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/ArtemisSecurityManager.java @@ -0,0 +1,83 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.messaging; + +import static cwms.cda.ApiServlet.CWMS_USERS_ROLE; + +import com.google.common.flogger.FluentLogger; +import cwms.cda.data.dao.AuthDao; +import cwms.cda.security.CwmsAuthException; +import cwms.cda.security.DataApiPrincipal; +import java.util.Set; +import javax.sql.DataSource; +import org.apache.activemq.artemis.core.security.CheckType; +import org.apache.activemq.artemis.core.security.Role; +import org.apache.activemq.artemis.spi.core.security.ActiveMQSecurityManager; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; + +final class ArtemisSecurityManager implements ActiveMQSecurityManager { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private final DataSource dataSource; + private final String cdaUser; + + ArtemisSecurityManager(DataSource dataSource) { + this.dataSource = dataSource; + cdaUser = DSL.using(dataSource, SQLDialect.ORACLE18C) + .connectionResult(c -> c.getMetaData().getUserName()); + } + + @Override + public boolean validateUser(String user, String password) { + return validate(user, password); + } + + @Override + public boolean validateUserAndRole(String user, String password, Set roles, CheckType checkType) { + //CDA User is allowed to send and manage messages for the invm acceptor. + //Other users are not allowed to send messages. + if (!cdaUser.equalsIgnoreCase(user) && (checkType == CheckType.SEND || checkType == CheckType.MANAGE)) { + LOGGER.atWarning().log("User: " + user + + " attempting to access Artemis Server with check type: " + checkType + + " Only message consumption is supported."); + return false; + } + return validate(user, password); + } + + private boolean validate(String user, String password) { + AuthDao instance = AuthDao.getInstance(DSL.using(dataSource, SQLDialect.ORACLE18C)); + boolean retval = false; + try { + DataApiPrincipal principal = instance.getByApiKey(password); + retval = principal.getName().equalsIgnoreCase(user) + && principal.getRoles().contains(new cwms.cda.security.Role(CWMS_USERS_ROLE)); + } catch (CwmsAuthException ex) { + LOGGER.atWarning().withCause(ex).log("Unauthenticated user: " + user + + " attempting to access Artemis Server"); + } + return retval; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelRouter.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelRouter.java new file mode 100644 index 000000000..03e3cc69c --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelRouter.java @@ -0,0 +1,242 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.messaging; + +import static java.lang.String.format; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.DSL.table; + +import com.google.common.flogger.FluentLogger; +import cwms.cda.ApiServlet; +import cwms.cda.data.dao.AuthDao; +import cwms.cda.data.dto.auth.ApiKey; +import cwms.cda.security.DataApiPrincipal; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.jms.ConnectionFactory; +import javax.jms.TopicConnectionFactory; +import javax.sql.DataSource; +import oracle.jms.AQjmsFactory; +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.apache.activemq.artemis.api.core.ActiveMQExceptionType; +import org.apache.activemq.artemis.core.server.ServerConsumer; +import org.apache.activemq.artemis.core.server.plugin.ActiveMQServerPlugin; +import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; +import org.apache.camel.CamelContext; +import org.apache.camel.component.jms.JmsComponent; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.model.RouteDefinition; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Record1; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; + +final class CamelRouter implements ActiveMQServerPlugin { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final String ORACLE_QUEUE_SOURCE = "oracleAQ"; + private static final String ARTEMIS_QUEUE_SOURCE = "artemis"; + private final CamelContext camelContext; + private final Map routeDefinitions; + private final String oracleAqClientId; + + CamelRouter(DataSource cwms) throws Exception { + oracleAqClientId = getClientId(); + camelContext = initCamel(cwms); + routeDefinitions = buildRouteDefinitions(cwms); + camelContext.addRouteDefinitions(routeDefinitions.values()); + } + + private CamelContext initCamel(DataSource cwms) { + try { + //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection + //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it + DefaultCamelContext camel = new DefaultCamelContext(); + DataSourceWrapper dataSource = new DataSourceWrapper(cwms); + TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(dataSource, true); + camel.addComponent(ORACLE_QUEUE_SOURCE, JmsComponent.jmsComponent(connectionFactory)); + + DSLContext context = DSL.using(cwms, SQLDialect.ORACLE18C); + String cdaUser = context + .connectionResult(c -> c.getMetaData().getUserName()); + String apiKey = createApiKey(context, cdaUser); + ConnectionFactory artemisConnectionFactory = new ActiveMQJMSConnectionFactory("vm://0", cdaUser, apiKey); + camel.addComponent(ARTEMIS_QUEUE_SOURCE, JmsComponent.jmsComponent(artemisConnectionFactory)); + camel.start(); + return camel; + } catch (Exception e) { + throw new IllegalStateException("Unable to setup Queues", e); + } + } + + private Map buildRouteDefinitions(DataSource cwms) { + DSLContext create = DSL.using(cwms, SQLDialect.ORACLE18C); + Field field = field(name("OWNER")).concat(".").concat(field(name("NAME"))).as("queue"); + return create.select(field) + .from(table(name("DBA_QUEUES"))) + .where(field(name("OWNER")).eq("CWMS_20")) + .and(field(name("QUEUE_TYPE")).eq("NORMAL_QUEUE")) + .fetch() + .stream() + .map(Record1::component1) + .distinct() + .map(OracleQueue::new) + .collect(toMap(q -> q, this::queueToRoute)); + } + + private RouteDefinition queueToRoute(OracleQueue queue) { + RouteDefinition routeDefinition = new RouteDefinition(); + String durableSub = (ApiServlet.APPLICATION_TITLE + "_" + queue.getOracleQueueName()) + .replace(" ", "_") + .replace(".", "_"); + String fromOracleRoute = format("%s:topic:%s?durableSubscriptionName=%s&clientId=%s", ORACLE_QUEUE_SOURCE, + queue.getOracleQueueName(), durableSub, oracleAqClientId); + String[] topics = queue.getTopicIds() + .stream() + .map(CamelRouter::createArtemisLabel) + .toArray(String[]::new); + routeDefinition.id(queue.getOracleQueueName()); + routeDefinition.from(fromOracleRoute) + .log("Received message from ActiveMQ.Queue : ${body}") + .process(new MapMessageToJsonProcessor(camelContext)) + .to(topics) + .autoStartup(false); + return routeDefinition; + } + + private static String getClientId() { + try { + String host = InetAddress.getLocalHost().getCanonicalHostName().replace("/", "_"); + return "CDA_" + host.replace(".", "_").replace(":", "_"); + } catch (UnknownHostException e) { + throw new IllegalStateException("Cannot obtain local host name for durable subscription queue setup", e); + } + } + + @Override + public void afterCreateConsumer(ServerConsumer consumer) throws ActiveMQException { + String routeId = consumer.getQueueAddress().toString(); + String label = createArtemisLabel(routeId); + List routeDefinition = routeDefinitions.values() + .stream() + .filter(r -> r.getOutputs().stream().anyMatch(o -> o.getLabel().equals(label))) + .collect(toList()); + if (routeDefinition.isEmpty()) { + throw new ActiveMQException(ActiveMQExceptionType.QUEUE_DOES_NOT_EXIST, + "Route for id: " + routeId + " does not exit"); + } + try { + for (RouteDefinition route : routeDefinition) { + //Camel handles synchronization internally + //Calling startRoute on an already started route is innocuous + camelContext.startRoute(route.getId()); + } + } catch (Exception e) { + throw new ActiveMQException("Could not start route: " + routeId, e, + ActiveMQExceptionType.GENERIC_EXCEPTION); + } + } + + Collection getTopics(String office) { + return routeDefinitions.keySet().stream() + .filter(q -> office == null || q.office.equalsIgnoreCase(office)) + .map(OracleQueue::getTopicIds) + .flatMap(Collection::stream) + .collect(toSet()); + } + + private static String createArtemisLabel(String routeId) { + return format("%s:topic:%s", ARTEMIS_QUEUE_SOURCE, routeId); + } + + void stop() throws Exception { + camelContext.stop(); + } + + private String createApiKey(DSLContext context, String user) { + AuthDao instance = AuthDao.getInstance(context); + UUID uuid = UUID.randomUUID(); + DataApiPrincipal principal = new DataApiPrincipal(user, new HashSet<>()); + ZonedDateTime now = ZonedDateTime.now(); + //TODO: Expiration should be handled more gracefully. + // This assumes no new queues are accessed after three months of uptime + //TODO: cda_camel_invm needs to be unique per instance of CDA. Not sure how to handle that at the moment. + // for now using current epoch millis. This unfortunately leaves old keys between restarts. + String keyName = "cda_camel_invm_" + Instant.now().toEpochMilli(); + ApiKey apiKey = new ApiKey(user, keyName, uuid.toString(), now, now.plusMonths(3)); + return instance.createApiKey(principal, apiKey).getApiKey(); + } + + private static final class OracleQueue { + private static final Pattern ORACLE_QUEUE_PATTERN = + Pattern.compile("CWMS_20\\.(?[A-Z]+)_(?.*)"); + private final String oracleQueueName; + private final String office; + private final String queueGroup; + + private OracleQueue(String oracleQueueName) { + this.oracleQueueName = oracleQueueName; + Matcher matcher = ORACLE_QUEUE_PATTERN.matcher(oracleQueueName); + if (matcher.matches()) { + this.office = matcher.group("office"); + this.queueGroup = matcher.group("queueGroup"); + } else { + LOGGER.atInfo().log("Oracle queue:" + oracleQueueName + " did not match standard pattern: " + + ORACLE_QUEUE_PATTERN.pattern() + " Artemis topic will use the Oracle queue name as-is."); + this.office = null; + this.queueGroup = null; + } + } + + private String getOracleQueueName() { + return this.oracleQueueName; + } + + private Set getTopicIds() { + Set retval = new HashSet<>(); + if (this.office != null && queueGroup != null) { + retval.add("CDA." + this.office + ".ALL"); + retval.add("CDA." + this.office + "." + this.queueGroup); + } else { + retval.add(this.oracleQueueName); + } + return retval; + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java deleted file mode 100644 index 576cebaa9..000000000 --- a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CamelServletContextListener.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2024 Hydrologic Engineering Center - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package cwms.cda.api.messaging; - -import hec.io.FilePath; -import hec.io.HecFileImpl; -import hec.util.XMLUtilities; -import oracle.jms.AQjmsFactory; -import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; -import org.apache.activemq.artemis.core.config.impl.FileConfiguration; -import org.apache.activemq.artemis.core.server.ActiveMQServer; -import org.apache.activemq.artemis.core.server.ActiveMQServers; -import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory; -import org.apache.camel.CamelContext; -import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.component.jms.JmsComponent; -import org.apache.camel.impl.DefaultCamelContext; -import org.w3c.dom.Document; -import org.w3c.dom.Element; - -import javax.annotation.Resource; -import javax.jms.ConnectionFactory; -import javax.jms.TopicConnectionFactory; -import javax.servlet.ServletContextEvent; -import javax.servlet.ServletContextListener; -import javax.servlet.annotation.WebListener; -import javax.sql.DataSource; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import java.io.File; -import java.net.InetAddress; - -@WebListener -public final class CamelServletContextListener implements ServletContextListener { - - @Resource(name = "jdbc/CWMS3") - DataSource cwms; - private DefaultCamelContext camelContext; - - @Override - public void contextInitialized(ServletContextEvent servletContextEvent) { - try { - //wrapped DelegatingDataSource is used because internally AQJMS casts the returned connection - //as an OracleConnection, but the JNDI pool is returning us a proxy, so unwrap it - camelContext = new DefaultCamelContext(); - TopicConnectionFactory connectionFactory = AQjmsFactory.getTopicConnectionFactory(new DataSourceWrapper(cwms), true); - camelContext.addComponent("oracleAQ", JmsComponent.jmsComponent(connectionFactory)); - File brokerXmlFile = new File("src/test/resources/tomcat/conf/broker.xml").getAbsoluteFile(); - DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); - dbFactory.setNamespaceAware(true); - DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); - Document doc = dBuilder.parse(brokerXmlFile); - doc.getDocumentElement().normalize(); - Element rootElement = doc.getDocumentElement(); - FileConfiguration configuration = new FileConfiguration(); - configuration.parse(rootElement, brokerXmlFile.toURI().toURL()); - ActiveMQServer server = ActiveMQServers.newActiveMQServer(configuration); - ConnectionFactory artemisConnectionFactory = new ActiveMQJMSConnectionFactory("vm://0"); - camelContext.addComponent("artemis", JmsComponent.jmsComponent(artemisConnectionFactory)); - camelContext.addRoutes(new RouteBuilder() { - public void configure() { - //TODO: configure Oracle Queue name for office - //TODO: determine durable subscription name - should be unique to CDA instance? - //TODO: determine clientId - should be unique to CDA version? - from("oracleAQ:topic:CWMS_20.SWT_TS_STORED?durableSubscriptionName=CDA_SWT_TS_STORED&clientId=CDA") - .log("Received message from ActiveMQ.Queue : ${body}") - .process(new MapMessageToJsonProcessor(camelContext)) - //TODO: define standard naming - //TODO: register artemis queue names with Swagger UI - .to("artemis:topic:SWT_TS_STORED"); - } - }); - server.start(); - camelContext.start(); - } catch (Exception e) { - throw new IllegalStateException("Unable to setup Queues", e); - } - } - - @Override - public void contextDestroyed(ServletContextEvent servletContextEvent) { - try { - camelContext.stop(); - } catch (Exception e) { - throw new IllegalStateException("Unable to stop Camel context during servlet shutdown"); - } - } -} diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java new file mode 100644 index 000000000..1eabd60e8 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java @@ -0,0 +1,169 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api.messaging; + +import static com.codahale.metrics.MetricRegistry.name; +import static cwms.cda.api.Controllers.GET_ALL; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.RESULTS; +import static cwms.cda.api.Controllers.SIZE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.queryParamAsClass; +import static java.util.stream.Collectors.toList; + +import com.codahale.metrics.Histogram; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import com.google.common.flogger.FluentLogger; +import cwms.cda.api.Controllers; +import cwms.cda.data.dto.messaging.CdaTopics; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.sql.DataSource; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.activemq.artemis.api.core.TransportConfiguration; +import org.apache.activemq.artemis.core.config.impl.FileConfiguration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.ActiveMQServers; +import org.apache.activemq.artemis.spi.core.security.ActiveMQJAASSecurityManager; +import org.jetbrains.annotations.NotNull; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +public final class CdaTopicHandler implements Handler { + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + private static final String TAG = "Messaging"; + private final MetricRegistry metrics; + private final Histogram requestResultSize; + private ActiveMQServer artemis; + private CamelRouter router; + + public CdaTopicHandler(DataSource cwms, MetricRegistry metrics) { + this.metrics = metrics; + this.requestResultSize = this.metrics.histogram((name(CdaTopicHandler.class.getName(), RESULTS, SIZE))); + try { + File brokerXmlFile = new File("src/test/resources/tomcat/conf/broker.xml").getAbsoluteFile(); + if (brokerXmlFile.exists()) { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + dbFactory.setExpandEntityReferences(false); + dbFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + dbFactory.setNamespaceAware(true); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(brokerXmlFile); + doc.getDocumentElement().normalize(); + Element rootElement = doc.getDocumentElement(); + FileConfiguration configuration = new FileConfiguration(); + configuration.parse(rootElement, brokerXmlFile.toURI().toURL()); + artemis = ActiveMQServers.newActiveMQServer(configuration); + router = new CamelRouter(cwms); + artemis.registerBrokerPlugin(router); + artemis.setSecurityManager(new ArtemisSecurityManager(cwms)); + artemis.start(); + } + } catch (Exception e) { + throw new IllegalStateException("Unable to setup Queues", e); + } + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi( + description = "Request the list of supported CDA topics in alphabetical order. " + + "Additional information for the host address of the messaging server is also provided.", + queryParams = { + @OpenApiParam(name = OFFICE, + description = "Specifies the owning office. If this field is not " + + "specified, matching information from all offices shall be " + + "returned."), + }, + responses = {@OpenApiResponse(status = STATUS_200, + description = "A list of supported CDA topics.", + content = { + @OpenApiContent(type = Formats.JSONV1, from = CdaTopics.class), + @OpenApiContent(type = Formats.JSON, from = CdaTopics.class) + }) + }, + method = HttpMethod.GET, + tags = {TAG} + ) + @Override + public void handle(@NotNull Context ctx) throws Exception { + try (final Timer.Context ignored = markAndTime(GET_ALL)) { + String office = ctx.queryParam(OFFICE); + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, CdaTopics.class); + Collection topics = router.getTopics(office); + List> configurations = new ArrayList<>(); + if(artemis.isStarted()) { + configurations = artemis.getConfiguration().getAcceptorConfigurations().stream() + .map(TransportConfiguration::getParams) + //Need to filter out the In-VM acceptor + .filter(s -> s.containsKey("host")) + .collect(toList()); + } + Set protocols = artemis.getRemotingService().getProtocolFactoryMap().keySet(); + CdaTopics cdaTopics = new CdaTopics(configurations, protocols, topics); + String result = Formats.format(contentType, cdaTopics); + ctx.result(result); + ctx.contentType(contentType.toString()); + requestResultSize.update(result.length()); + } + } + + public void shutdown() { + if (artemis != null) { + try { + artemis.stop(); + } catch (Exception e) { + LOGGER.atWarning().withCause(e).log("Unable to stop Artemis server during servlet shutdown"); + } + } + if (router != null) { + try { + router.stop(); + } catch (Exception e) { + LOGGER.atWarning().withCause(e).log("Unable to stop Camel Route Handler during servlet shutdown"); + } + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/messaging/CdaTopics.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/messaging/CdaTopics.java new file mode 100644 index 000000000..420cf556f --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/messaging/CdaTopics.java @@ -0,0 +1,83 @@ +/* + * MIT License + * + * Copyright (c) 2024 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dto.messaging; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; +import java.util.Set; +import java.util.TreeSet; + +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, aliases = {Formats.JSON, Formats.DEFAULT}) +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +public final class CdaTopics extends CwmsDTOBase { + private List> serverConfigurations = new ArrayList<>(); + private final NavigableSet supportedProtocols = new TreeSet<>(); + private final NavigableSet topics = new TreeSet<>(); + + public CdaTopics() { + } + + public CdaTopics(List> serverConfigurations, Collection supportedProtocols, Collection topics) { + this.serverConfigurations.addAll(serverConfigurations); + this.supportedProtocols.addAll(supportedProtocols); + this.topics.addAll(topics); + } + + public List> getServerConfigurations() { + return serverConfigurations; + } + + public NavigableSet getSupportedProtocols() { + return this.supportedProtocols; + } + + public NavigableSet getTopics() { + return this.topics; + } + + public void setServerConfigurations(List> serverConfigurations) { + this.serverConfigurations = serverConfigurations; + } + + public void setSupportedProtocols(Set supportedProtocols) { + + this.supportedProtocols.addAll(supportedProtocols); + } + + public void setTopics(Set topics) { + this.topics.addAll(topics); + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/messaging/cda_topics.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/messaging/cda_topics.json new file mode 100644 index 000000000..d8736e85d --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/messaging/cda_topics.json @@ -0,0 +1,275 @@ +{ + "server-configurations": [ + { + "scheme": "tcp", + "port": "61616", + "host": "localhost" + } + ], + "supported-protocols": [ + "CORE", + "STOMP" + ], + "topics": [ + "CDA.CERL.ALL", + "CDA.CERL.REALTIME_OPS", + "CDA.CERL.STATUS", + "CDA.CERL.TS_STORED", + "CDA.CHL.ALL", + "CDA.CHL.REALTIME_OPS", + "CDA.CHL.STATUS", + "CDA.CHL.TS_STORED", + "CDA.CPC.ALL", + "CDA.CPC.REALTIME_OPS", + "CDA.CPC.STATUS", + "CDA.CPC.TS_STORED", + "CDA.CRREL.ALL", + "CDA.CRREL.REALTIME_OPS", + "CDA.CRREL.STATUS", + "CDA.CRREL.TS_STORED", + "CDA.EL.ALL", + "CDA.EL.REALTIME_OPS", + "CDA.EL.STATUS", + "CDA.EL.TS_STORED", + "CDA.ERD.ALL", + "CDA.ERD.REALTIME_OPS", + "CDA.ERD.STATUS", + "CDA.ERD.TS_STORED", + "CDA.GSL.ALL", + "CDA.GSL.REALTIME_OPS", + "CDA.GSL.STATUS", + "CDA.GSL.TS_STORED", + "CDA.HEC.ALL", + "CDA.HEC.REALTIME_OPS", + "CDA.HEC.STATUS", + "CDA.HEC.TS_STORED", + "CDA.ITL.ALL", + "CDA.ITL.REALTIME_OPS", + "CDA.ITL.STATUS", + "CDA.ITL.TS_STORED", + "CDA.IWR.ALL", + "CDA.IWR.REALTIME_OPS", + "CDA.IWR.STATUS", + "CDA.IWR.TS_STORED", + "CDA.LCRA.ALL", + "CDA.LCRA.REALTIME_OPS", + "CDA.LCRA.STATUS", + "CDA.LCRA.TS_STORED", + "CDA.LRB.ALL", + "CDA.LRB.REALTIME_OPS", + "CDA.LRB.STATUS", + "CDA.LRB.TS_STORED", + "CDA.LRC.ALL", + "CDA.LRC.REALTIME_OPS", + "CDA.LRC.STATUS", + "CDA.LRC.TS_STORED", + "CDA.LRD.ALL", + "CDA.LRD.REALTIME_OPS", + "CDA.LRD.STATUS", + "CDA.LRD.TS_STORED", + "CDA.LRDG.ALL", + "CDA.LRDG.REALTIME_OPS", + "CDA.LRDG.STATUS", + "CDA.LRDG.TS_STORED", + "CDA.LRDO.ALL", + "CDA.LRDO.REALTIME_OPS", + "CDA.LRDO.STATUS", + "CDA.LRDO.TS_STORED", + "CDA.LRE.ALL", + "CDA.LRE.REALTIME_OPS", + "CDA.LRE.STATUS", + "CDA.LRE.TS_STORED", + "CDA.LRH.ALL", + "CDA.LRH.REALTIME_OPS", + "CDA.LRH.STATUS", + "CDA.LRH.TS_STORED", + "CDA.LRL.ALL", + "CDA.LRL.REALTIME_OPS", + "CDA.LRL.STATUS", + "CDA.LRL.TS_STORED", + "CDA.LRN.ALL", + "CDA.LRN.REALTIME_OPS", + "CDA.LRN.STATUS", + "CDA.LRN.TS_STORED", + "CDA.LRP.ALL", + "CDA.LRP.REALTIME_OPS", + "CDA.LRP.STATUS", + "CDA.LRP.TS_STORED", + "CDA.MVD.ALL", + "CDA.MVD.REALTIME_OPS", + "CDA.MVD.STATUS", + "CDA.MVD.TS_STORED", + "CDA.MVK.ALL", + "CDA.MVK.REALTIME_OPS", + "CDA.MVK.STATUS", + "CDA.MVK.TS_STORED", + "CDA.MVM.ALL", + "CDA.MVM.REALTIME_OPS", + "CDA.MVM.STATUS", + "CDA.MVM.TS_STORED", + "CDA.MVN.ALL", + "CDA.MVN.REALTIME_OPS", + "CDA.MVN.STATUS", + "CDA.MVN.TS_STORED", + "CDA.MVP.ALL", + "CDA.MVP.REALTIME_OPS", + "CDA.MVP.STATUS", + "CDA.MVP.TS_STORED", + "CDA.MVR.ALL", + "CDA.MVR.REALTIME_OPS", + "CDA.MVR.STATUS", + "CDA.MVR.TS_STORED", + "CDA.MVS.ALL", + "CDA.MVS.REALTIME_OPS", + "CDA.MVS.STATUS", + "CDA.MVS.TS_STORED", + "CDA.NAB.ALL", + "CDA.NAB.REALTIME_OPS", + "CDA.NAB.STATUS", + "CDA.NAB.TS_STORED", + "CDA.NAD.ALL", + "CDA.NAD.REALTIME_OPS", + "CDA.NAD.STATUS", + "CDA.NAD.TS_STORED", + "CDA.NAE.ALL", + "CDA.NAE.REALTIME_OPS", + "CDA.NAE.STATUS", + "CDA.NAE.TS_STORED", + "CDA.NAN.ALL", + "CDA.NAN.REALTIME_OPS", + "CDA.NAN.STATUS", + "CDA.NAN.TS_STORED", + "CDA.NAO.ALL", + "CDA.NAO.REALTIME_OPS", + "CDA.NAO.STATUS", + "CDA.NAO.TS_STORED", + "CDA.NAP.ALL", + "CDA.NAP.REALTIME_OPS", + "CDA.NAP.STATUS", + "CDA.NAP.TS_STORED", + "CDA.NDC.ALL", + "CDA.NDC.REALTIME_OPS", + "CDA.NDC.STATUS", + "CDA.NDC.TS_STORED", + "CDA.NWD.ALL", + "CDA.NWD.REALTIME_OPS", + "CDA.NWD.STATUS", + "CDA.NWD.TS_STORED", + "CDA.NWDM.ALL", + "CDA.NWDM.REALTIME_OPS", + "CDA.NWDM.STATUS", + "CDA.NWDM.TS_STORED", + "CDA.NWDP.ALL", + "CDA.NWDP.REALTIME_OPS", + "CDA.NWDP.STATUS", + "CDA.NWDP.TS_STORED", + "CDA.NWK.ALL", + "CDA.NWK.REALTIME_OPS", + "CDA.NWK.STATUS", + "CDA.NWK.TS_STORED", + "CDA.NWO.ALL", + "CDA.NWO.REALTIME_OPS", + "CDA.NWO.STATUS", + "CDA.NWO.TS_STORED", + "CDA.NWP.ALL", + "CDA.NWP.REALTIME_OPS", + "CDA.NWP.STATUS", + "CDA.NWP.TS_STORED", + "CDA.NWS.ALL", + "CDA.NWS.REALTIME_OPS", + "CDA.NWS.STATUS", + "CDA.NWS.TS_STORED", + "CDA.NWW.ALL", + "CDA.NWW.REALTIME_OPS", + "CDA.NWW.STATUS", + "CDA.NWW.TS_STORED", + "CDA.POA.ALL", + "CDA.POA.REALTIME_OPS", + "CDA.POA.STATUS", + "CDA.POA.TS_STORED", + "CDA.POD.ALL", + "CDA.POD.REALTIME_OPS", + "CDA.POD.STATUS", + "CDA.POD.TS_STORED", + "CDA.POH.ALL", + "CDA.POH.REALTIME_OPS", + "CDA.POH.STATUS", + "CDA.POH.TS_STORED", + "CDA.SAC.ALL", + "CDA.SAC.REALTIME_OPS", + "CDA.SAC.STATUS", + "CDA.SAC.TS_STORED", + "CDA.SAD.ALL", + "CDA.SAD.REALTIME_OPS", + "CDA.SAD.STATUS", + "CDA.SAD.TS_STORED", + "CDA.SAJ.ALL", + "CDA.SAJ.REALTIME_OPS", + "CDA.SAJ.STATUS", + "CDA.SAJ.TS_STORED", + "CDA.SAM.ALL", + "CDA.SAM.REALTIME_OPS", + "CDA.SAM.STATUS", + "CDA.SAM.TS_STORED", + "CDA.SAS.ALL", + "CDA.SAS.REALTIME_OPS", + "CDA.SAS.STATUS", + "CDA.SAS.TS_STORED", + "CDA.SAW.ALL", + "CDA.SAW.REALTIME_OPS", + "CDA.SAW.STATUS", + "CDA.SAW.TS_STORED", + "CDA.SPA.ALL", + "CDA.SPA.REALTIME_OPS", + "CDA.SPA.STATUS", + "CDA.SPA.TS_STORED", + "CDA.SPD.ALL", + "CDA.SPD.REALTIME_OPS", + "CDA.SPD.STATUS", + "CDA.SPD.TS_STORED", + "CDA.SPK.ALL", + "CDA.SPK.REALTIME_OPS", + "CDA.SPK.STATUS", + "CDA.SPK.TS_STORED", + "CDA.SPL.ALL", + "CDA.SPL.REALTIME_OPS", + "CDA.SPL.STATUS", + "CDA.SPL.TS_STORED", + "CDA.SPN.ALL", + "CDA.SPN.REALTIME_OPS", + "CDA.SPN.STATUS", + "CDA.SPN.TS_STORED", + "CDA.SWD.ALL", + "CDA.SWD.REALTIME_OPS", + "CDA.SWD.STATUS", + "CDA.SWD.TS_STORED", + "CDA.SWF.ALL", + "CDA.SWF.REALTIME_OPS", + "CDA.SWF.STATUS", + "CDA.SWF.TS_STORED", + "CDA.SWG.ALL", + "CDA.SWG.REALTIME_OPS", + "CDA.SWG.STATUS", + "CDA.SWG.TS_STORED", + "CDA.SWL.ALL", + "CDA.SWL.REALTIME_OPS", + "CDA.SWL.STATUS", + "CDA.SWL.TS_STORED", + "CDA.SWT.ALL", + "CDA.SWT.REALTIME_OPS", + "CDA.SWT.STATUS", + "CDA.SWT.TS_STORED", + "CDA.TEC.ALL", + "CDA.TEC.REALTIME_OPS", + "CDA.TEC.STATUS", + "CDA.TEC.TS_STORED", + "CDA.WCSC.ALL", + "CDA.WCSC.REALTIME_OPS", + "CDA.WCSC.STATUS", + "CDA.WCSC.TS_STORED", + "CDA.WPC.ALL", + "CDA.WPC.REALTIME_OPS", + "CDA.WPC.STATUS", + "CDA.WPC.TS_STORED" + ] +} \ No newline at end of file diff --git a/cwms-data-api/src/test/resources/tomcat/conf/broker.xml b/cwms-data-api/src/test/resources/tomcat/conf/broker.xml index f1d08feaa..884eab602 100644 --- a/cwms-data-api/src/test/resources/tomcat/conf/broker.xml +++ b/cwms-data-api/src/test/resources/tomcat/conf/broker.xml @@ -30,7 +30,6 @@ ActiveMQServer false - false tcp://localhost:61616?httpEnabled=true vm://0 diff --git a/websocket-testing/src/main/javascript/stomp-ws-testing.js b/websocket-testing/src/main/javascript/stomp-ws-testing.js index ead88624d..6dcdf2446 100644 --- a/websocket-testing/src/main/javascript/stomp-ws-testing.js +++ b/websocket-testing/src/main/javascript/stomp-ws-testing.js @@ -29,10 +29,18 @@ import {WebSocket} from 'ws'; Object.assign(global, {WebSocket}); const client = new Client({ logRawCommunication: true, + connectHeaders: { + login: 'M5HECTEST', + passcode: 'testkey', + }, brokerURL: 'ws://localhost:61616/topic', connectionTimeout: 1000, onConnect: () => { console.log("Connected") - client.subscribe('SWT_TS_STORED', message => { - console.log(`Received: ${message.body}`); + client.subscribe('CDA.SWT.ALL', message => { + console.log(`Received: ${message.body} from CDA.SWT.ALL`); + message.ack(); + }, {ack: 'client'}); + client.subscribe('CDA.SWT.TS_STORED', message => { + console.log(`Received: ${message.body} from CDA.SWT.TS_STORED`); message.ack(); }, {ack: 'client'}); }, onStompError: (frame) => { From 96b6ed55660a032425a3195698f91c0cb8a3f995 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Wed, 17 Jul 2024 15:12:01 -0700 Subject: [PATCH 08/11] fix feature flag so that it can disable messaging --- cwms-data-api/build.gradle | 1 + cwms-data-api/src/main/java/cwms/cda/ApiServlet.java | 2 +- gradle.properties.example | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index 4604f76b1..c6e3ec5f6 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -243,6 +243,7 @@ task run(type: JavaExec) { mainClass = "fixtures.TomcatServer" systemProperties += project.properties.findAll { k, v -> k.startsWith("RADAR") } systemProperties += project.properties.findAll { k, v -> k.startsWith("CDA") } + systemProperties += project.properties.findAll { k, v -> k.startsWith("cda") } def context = project.findProperty("cda.war.context") ?: "spk-data" diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index dd44db5b0..f7e8887ff 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -517,7 +517,7 @@ protected void configureRoutes() { new PropertyController(metrics), requiredRoles,1, TimeUnit.DAYS); cdaCrudCache(format("/lookup-types/{%s}", Controllers.NAME), new LookupTypeController(metrics), requiredRoles,1, TimeUnit.DAYS); - if(true || Boolean.getBoolean("cwms.data.api.messaging.enabled")) { + if(Boolean.getBoolean("cwms.data.api.messaging.enabled")) { //TODO: setup separate data source for persistent connections to Oracle AQ cdaTopicHandler = new CdaTopicHandler(cwms, metrics); get("/cda-topics", cdaTopicHandler); diff --git a/gradle.properties.example b/gradle.properties.example index f289fb6b3..9da79aacb 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -26,4 +26,8 @@ cda.war.context=cwms-data #testcontainer.cwms.bypass.office.id=HQ ## eroc must be lower case. #testcontainer.cwms.bypass.office.eroc=l2 -#testcontainer.cwms.bypass.network=database_net \ No newline at end of file +#testcontainer.cwms.bypass.network=database_net + +## Turns on messaging via Oracle AQ/ArtemisMQ/Apache Camel +## - make sure to increase number of connections in pool to support queues +#cwms.data.api.messaging.enabled=true \ No newline at end of file From 32752b5d25d289588764c3cfa2bd105fee7030cb Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Wed, 17 Jul 2024 15:15:54 -0700 Subject: [PATCH 09/11] fix typo --- cwms-data-api/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index c6e3ec5f6..4cea0e1de 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -243,7 +243,7 @@ task run(type: JavaExec) { mainClass = "fixtures.TomcatServer" systemProperties += project.properties.findAll { k, v -> k.startsWith("RADAR") } systemProperties += project.properties.findAll { k, v -> k.startsWith("CDA") } - systemProperties += project.properties.findAll { k, v -> k.startsWith("cda") } + systemProperties += project.properties.findAll { k, v -> k.startsWith("cwms") } def context = project.findProperty("cda.war.context") ?: "spk-data" From 512016088455db583412147a55b54081f1d78348 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Wed, 17 Jul 2024 15:28:17 -0700 Subject: [PATCH 10/11] remove embankments from bad merge --- cwms-data-api/src/main/java/cwms/cda/ApiServlet.java | 1 - 1 file changed, 1 deletion(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index f7e8887ff..70ec5ed8a 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -188,7 +188,6 @@ "/projects/*", "/properties/*", "/lookup-types/*", - "/embankments/*", "/cda-topics" }) public class ApiServlet extends HttpServlet { From 81d186502b1a0c16471160e9ca3eacabb1886278 Mon Sep 17 00:00:00 2001 From: Adam Korynta Date: Thu, 18 Jul 2024 08:16:09 -0700 Subject: [PATCH 11/11] use system property or environment variable for broker.xml --- .../cda/api/messaging/CdaTopicHandler.java | 46 ++++++++++--------- gradle.properties.example | 3 +- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java index 1eabd60e8..f664ac748 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/messaging/CdaTopicHandler.java @@ -30,7 +30,6 @@ import static cwms.cda.api.Controllers.RESULTS; import static cwms.cda.api.Controllers.SIZE; import static cwms.cda.api.Controllers.STATUS_200; -import static cwms.cda.api.Controllers.queryParamAsClass; import static java.util.stream.Collectors.toList; import com.codahale.metrics.Histogram; @@ -63,7 +62,6 @@ import org.apache.activemq.artemis.core.config.impl.FileConfiguration; import org.apache.activemq.artemis.core.server.ActiveMQServer; import org.apache.activemq.artemis.core.server.ActiveMQServers; -import org.apache.activemq.artemis.spi.core.security.ActiveMQJAASSecurityManager; import org.jetbrains.annotations.NotNull; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -79,25 +77,31 @@ public final class CdaTopicHandler implements Handler { public CdaTopicHandler(DataSource cwms, MetricRegistry metrics) { this.metrics = metrics; this.requestResultSize = this.metrics.histogram((name(CdaTopicHandler.class.getName(), RESULTS, SIZE))); + String brokerFile = System.getProperty("cwms.data.api.messaging.artemis.broker.file", + System.getenv("CDA_ARTEMIS_BROKER_FILE")); + if (brokerFile == null) { + return; + } + File brokerXmlFile = new File(brokerFile).getAbsoluteFile(); + if (!brokerXmlFile.exists()) { + return; + } try { - File brokerXmlFile = new File("src/test/resources/tomcat/conf/broker.xml").getAbsoluteFile(); - if (brokerXmlFile.exists()) { - DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); - dbFactory.setExpandEntityReferences(false); - dbFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - dbFactory.setNamespaceAware(true); - DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); - Document doc = dBuilder.parse(brokerXmlFile); - doc.getDocumentElement().normalize(); - Element rootElement = doc.getDocumentElement(); - FileConfiguration configuration = new FileConfiguration(); - configuration.parse(rootElement, brokerXmlFile.toURI().toURL()); - artemis = ActiveMQServers.newActiveMQServer(configuration); - router = new CamelRouter(cwms); - artemis.registerBrokerPlugin(router); - artemis.setSecurityManager(new ArtemisSecurityManager(cwms)); - artemis.start(); - } + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + dbFactory.setExpandEntityReferences(false); + dbFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + dbFactory.setNamespaceAware(true); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(brokerXmlFile); + doc.getDocumentElement().normalize(); + Element rootElement = doc.getDocumentElement(); + FileConfiguration configuration = new FileConfiguration(); + configuration.parse(rootElement, brokerXmlFile.toURI().toURL()); + artemis = ActiveMQServers.newActiveMQServer(configuration); + router = new CamelRouter(cwms); + artemis.registerBrokerPlugin(router); + artemis.setSecurityManager(new ArtemisSecurityManager(cwms)); + artemis.start(); } catch (Exception e) { throw new IllegalStateException("Unable to setup Queues", e); } @@ -134,7 +138,7 @@ public void handle(@NotNull Context ctx) throws Exception { ContentType contentType = Formats.parseHeader(formatHeader, CdaTopics.class); Collection topics = router.getTopics(office); List> configurations = new ArrayList<>(); - if(artemis.isStarted()) { + if (artemis.isStarted()) { configurations = artemis.getConfiguration().getAcceptorConfigurations().stream() .map(TransportConfiguration::getParams) //Need to filter out the In-VM acceptor diff --git a/gradle.properties.example b/gradle.properties.example index 9da79aacb..60a30065a 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -30,4 +30,5 @@ cda.war.context=cwms-data ## Turns on messaging via Oracle AQ/ArtemisMQ/Apache Camel ## - make sure to increase number of connections in pool to support queues -#cwms.data.api.messaging.enabled=true \ No newline at end of file +#cwms.data.api.messaging.enabled=true +#cwms.data.api.messaging.artemis.broker.file=src/test/resources/tomcat/conf/broker.xml \ No newline at end of file