feat(core): runnable QQQ reference app (#4) #14
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# Core reference app
|
||||
|
||||
Build with Java 21 and Maven, then start the standalone core profile:
|
||||
|
||||
```sh
|
||||
mvn -pl qqq-all-app -am verify
|
||||
java -jar qqq-all-app/target/qqq-all-app-4.1.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0.0.1:8080/` and health at `/health`. Set `QQQ_ALL_PORT` to change the port and `QQQ_ALL_DATA_DIR` to change the default `./data` directory. On first start it seeds customers and orders in H2, order lines in SQLite, and products as JSON files. Artemis runs in the same JVM; inserts and updates to `order` publish to the `orderEvents` topic and trigger `syncOrder`.
|
||||
|
||||
Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. These identities are for local demonstrations; role-based permissions are wired in task C5.
|
||||
@@ -16,4 +16,76 @@
|
||||
<name>QQQ All - Reference Application</name>
|
||||
<description>Reference QQQ application with every backend, qBit, and the ESB wired, plus seeded demo data.</description>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-all-bom</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-sqlite</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-middleware-javalin</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-middleware-health</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-esb</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId></dependency>
|
||||
<dependency><groupId>org.xerial</groupId><artifactId>sqlite-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.apache.artemis</groupId><artifactId>artemis-jakarta-server</artifactId><version>2.57.0</version></dependency>
|
||||
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.2</version><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.kingsrook.qqq.all.QqqAllApplication</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes><exclude>META-INF/*.SF</exclude><exclude>META-INF/*.DSA</exclude><exclude>META-INF/*.RSA</exclude></excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<qqqAllJar>${project.build.directory}/${project.build.finalName}.jar</qqqAllJar>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals><goal>integration-test</goal><goal>verify</goal></goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
/** Creates the small, repeatable core demo data set before the web server starts. */
|
||||
final class CoreData
|
||||
{
|
||||
private CoreData()
|
||||
{
|
||||
}
|
||||
|
||||
static String h2DatabaseName(Path dataDirectory)
|
||||
{
|
||||
return dataDirectory.resolve("h2/qqq-all").toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
static Path sqlitePath(Path dataDirectory)
|
||||
{
|
||||
return dataDirectory.resolve("sqlite/qqq-all.db");
|
||||
}
|
||||
|
||||
static void seed(Path dataDirectory) throws IOException, SQLException
|
||||
{
|
||||
Files.createDirectories(dataDirectory.resolve("h2"));
|
||||
Files.createDirectories(dataDirectory.resolve("sqlite"));
|
||||
Files.createDirectories(dataDirectory.resolve("files/products"));
|
||||
|
||||
try(Connection connection = DriverManager.getConnection(
|
||||
"jdbc:h2:file:" + h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS customer (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(120), email VARCHAR(200))");
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS orders (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, order_no VARCHAR(40), customer_id INTEGER, status VARCHAR(40))");
|
||||
statement.execute("MERGE INTO customer (id, name, email) KEY (id) VALUES (1, 'Ada Lovelace', 'ada@example.test'), (2, 'Grace Hopper', 'grace@example.test')");
|
||||
statement.execute("MERGE INTO orders (id, order_no, customer_id, status) KEY (id) VALUES (1, 'ORD-1001', 1, 'NEW')");
|
||||
}
|
||||
|
||||
try(Connection connection = DriverManager.getConnection("jdbc:sqlite:" + sqlitePath(dataDirectory));
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE IF NOT EXISTS order_line (id INTEGER PRIMARY KEY, order_id INTEGER, product_id INTEGER, quantity INTEGER)");
|
||||
statement.execute("INSERT OR IGNORE INTO order_line (id, order_id, product_id, quantity) VALUES (1, 1, 1, 2)");
|
||||
}
|
||||
|
||||
Path product = dataDirectory.resolve("files/products/1.json");
|
||||
if(Files.notExists(product))
|
||||
{
|
||||
Files.writeString(product, "{\"id\":1,\"name\":\"QRun Starter Kit\",\"price\":29.00}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.esb.runtime.QEsbRuntime;
|
||||
|
||||
/** Launches the ESB subscriber through the common QQQ application launcher. */
|
||||
public class CoreEsbRuntimeService implements QRuntimeServiceInterface
|
||||
{
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return "coreEsb";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(QInstance instance)
|
||||
{
|
||||
QEsbRuntime.getInstance().start(instance);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
QEsbRuntime.getInstance().stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
|
||||
import org.apache.activemq.artemis.core.config.Configuration;
|
||||
import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl;
|
||||
import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ;
|
||||
|
||||
/** Owns the embedded broker and launcher as one closeable core profile. */
|
||||
final class CoreRuntime implements AutoCloseable
|
||||
{
|
||||
private final EmbeddedActiveMQ broker;
|
||||
private final QApplicationLauncher launcher;
|
||||
private final Thread shutdownHook;
|
||||
|
||||
private CoreRuntime(EmbeddedActiveMQ broker, QApplicationLauncher launcher)
|
||||
{
|
||||
this.broker = broker;
|
||||
this.launcher = launcher;
|
||||
this.shutdownHook = new Thread(this::close, "qqq-all-core-shutdown");
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
|
||||
static CoreRuntime launch(Path dataDirectory, Integer port) throws Exception
|
||||
{
|
||||
CoreData.seed(dataDirectory);
|
||||
Integer brokerPort = findFreePort();
|
||||
Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath();
|
||||
String brokerUrl = "tcp://127.0.0.1:" + brokerPort;
|
||||
Configuration configuration = new ConfigurationImpl()
|
||||
.setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false)
|
||||
.setBindingsDirectory(brokerDirectory.resolve("bindings").toString())
|
||||
.setJournalDirectory(brokerDirectory.resolve("journal").toString())
|
||||
.setPagingDirectory(brokerDirectory.resolve("paging").toString())
|
||||
.setLargeMessagesDirectory(brokerDirectory.resolve("large-messages").toString())
|
||||
.addAcceptorConfiguration("tcp", brokerUrl);
|
||||
EmbeddedActiveMQ broker = new EmbeddedActiveMQ().setConfiguration(configuration);
|
||||
broker.start();
|
||||
try
|
||||
{
|
||||
QApplicationLauncher launcher = QApplicationLauncher.run(
|
||||
new QqqAllApplication(dataDirectory, brokerUrl),
|
||||
new QApplicationLauncherConfig().withRegisterShutdownHook(false)
|
||||
.withServerCustomizer(server -> server.withPort(port)
|
||||
.withJavalinConfigCustomizer(config -> config.jetty.host = "127.0.0.1")));
|
||||
return new CoreRuntime(broker, launcher);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
broker.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer findFreePort() throws IOException
|
||||
{
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
QApplicationLauncher getLauncher()
|
||||
{
|
||||
return launcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
launcher.stop();
|
||||
try
|
||||
{
|
||||
broker.stop();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new IllegalStateException("Could not stop embedded Artemis", e);
|
||||
}
|
||||
if(Thread.currentThread() != shutdownHook)
|
||||
{
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
|
||||
/** Fixed identities for local mock authentication; the session cookie selects one. */
|
||||
public class DemoUsers implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
public static final String ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
public static final String DEMO_SESSION = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
Boolean admin = ADMIN_SESSION.equals(session.getUuid());
|
||||
String name = admin ? "Admin" : "Demo User";
|
||||
String userId = admin ? "core:admin" : "core:demo";
|
||||
session.getUser().setIdReference(userId);
|
||||
session.getUser().setFullName(name);
|
||||
session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.RecordFormat;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.sqlite.model.metadata.SQLiteBackendMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbDestinationType;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProcessMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProviderType;
|
||||
import com.kingsrook.qqq.esb.model.EsbTableEvent;
|
||||
import com.kingsrook.qqq.esb.model.EsbTableMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbTablePublication;
|
||||
import com.kingsrook.qqq.esb.model.EsbTrigger;
|
||||
import com.kingsrook.qqq.esb.model.QEsbDestinationMetaData;
|
||||
import com.kingsrook.qqq.esb.model.QEsbProviderMetaData;
|
||||
import com.kingsrook.qqq.middleware.health.JavalinHealthRouteProvider;
|
||||
import com.kingsrook.qqq.middleware.health.indicators.MemoryHealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
/** The self-contained core QQQ reference application. */
|
||||
public final class QqqAllApplication extends AbstractQQQApplication
|
||||
{
|
||||
private final Path dataDirectory;
|
||||
private final String brokerUrl;
|
||||
|
||||
public QqqAllApplication(Path dataDirectory, String brokerUrl)
|
||||
{
|
||||
this.dataDirectory = dataDirectory;
|
||||
this.brokerUrl = brokerUrl;
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Path dataDirectory = Path.of(System.getenv().getOrDefault("QQQ_ALL_DATA_DIR", "./data"));
|
||||
Integer port = Integer.valueOf(System.getenv().getOrDefault("QQQ_ALL_PORT", "8080"));
|
||||
CoreRuntime.launch(dataDirectory, port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = new QInstance().withFailOnMetaDataProducerError(true);
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData()
|
||||
.withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(DemoUsers.class)));
|
||||
|
||||
instance.addBackend(new RDBMSBackendMetaData()
|
||||
.withName("h2").withVendor("h2").withHostName("file")
|
||||
.withDatabaseName(CoreData.h2DatabaseName(dataDirectory)).withUsername("sa"));
|
||||
instance.addBackend(new SQLiteBackendMetaData().withName("sqlite")
|
||||
.withPath(CoreData.sqlitePath(dataDirectory).toAbsolutePath().toString()));
|
||||
instance.addBackend(new FilesystemBackendMetaData().withName("files")
|
||||
.withBasePath(dataDirectory.resolve("files").toAbsolutePath().toString()));
|
||||
|
||||
instance.addTable(new QTableMetaData().withName("customer").withLabel("Customer")
|
||||
.withBackendName("h2").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("email", QFieldType.STRING)));
|
||||
instance.addTable(new QTableMetaData().withName("order").withLabel("Order")
|
||||
.withBackendName("h2").withBackendDetails(new RDBMSTableBackendDetails().withTableName("orders"))
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("orderNo", QFieldType.STRING).withBackendName("order_no"))
|
||||
.withField(new QFieldMetaData("customerId", QFieldType.INTEGER).withBackendName("customer_id"))
|
||||
.withField(new QFieldMetaData("status", QFieldType.STRING))
|
||||
.withSupplementalMetaData(new EsbTableMetaData().withPublication(new EsbTablePublication()
|
||||
.withDestinationName("orderEvents").withEvents(List.of(EsbTableEvent.INSERT, EsbTableEvent.UPDATE)))));
|
||||
instance.addTable(new QTableMetaData().withName("orderLine").withLabel("Order Line")
|
||||
.withBackendName("sqlite").withBackendDetails(new RDBMSTableBackendDetails().withTableName("order_line"))
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("orderId", QFieldType.INTEGER).withBackendName("order_id"))
|
||||
.withField(new QFieldMetaData("productId", QFieldType.INTEGER).withBackendName("product_id"))
|
||||
.withField(new QFieldMetaData("quantity", QFieldType.INTEGER)));
|
||||
instance.addTable(new QTableMetaData().withName("product").withLabel("Product")
|
||||
.withBackendName("files").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new FilesystemTableBackendDetails()
|
||||
.withBasePath("products").withRecordFormat(RecordFormat.JSON)
|
||||
.withCardinality(Cardinality.MANY).withGlob("*.json"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("price", QFieldType.DECIMAL)));
|
||||
|
||||
instance.addProcess(new QProcessMetaData().withName("syncOrder").withLabel("Sync Order")
|
||||
.withTableName("order")
|
||||
.withStep(new QBackendStepMetaData().withName("sync")
|
||||
.withCode(new QCodeReference(SyncOrderStep.class)))
|
||||
.withSupplementalMetaData(new EsbProcessMetaData()
|
||||
.withTrigger(new EsbTrigger().withDestinationName("orderEvents"))));
|
||||
|
||||
EsbInstanceMetaData.of(instance).withInstanceName("qqqAll")
|
||||
.withProvider(new QEsbProviderMetaData().withName("artemis")
|
||||
.withType(EsbProviderType.ACTIVEMQ_ARTEMIS).withUrl(brokerUrl))
|
||||
.withDestination(new QEsbDestinationMetaData().withName("orderEvents")
|
||||
.withType(EsbDestinationType.TOPIC).withProviderName("artemis")
|
||||
.withDestinationName("qqq.all.orderEvents"));
|
||||
instance.withRuntimeService(new QCodeReference(CoreEsbRuntimeService.class));
|
||||
|
||||
instance.withSupplementalMetaData(new HealthCheckMetaData().withEnabled(true)
|
||||
.withEndpointPath("/health").withIndicators(List.of(new MemoryHealthIndicator().withThreshold(99))));
|
||||
QJavalinMetaData.ofOrWithNew(instance)
|
||||
.withAdditionalRouteProviderReference(new QCodeReference(JavalinHealthRouteProvider.class));
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** A visible effect for the demo and for the ESB integration test. */
|
||||
public static final class SyncOrderStep implements BackendStep
|
||||
{
|
||||
private static final AtomicInteger RUN_COUNT = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
RUN_COUNT.incrementAndGet();
|
||||
}
|
||||
|
||||
public static Integer getRunCount()
|
||||
{
|
||||
return RUN_COUNT.get();
|
||||
}
|
||||
|
||||
public static void reset()
|
||||
{
|
||||
RUN_COUNT.set(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
|
||||
import com.kingsrook.qqq.esb.runtime.EsbTriggerState;
|
||||
import com.kingsrook.qqq.esb.runtime.QEsbRuntime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Exercises the assembled core application, including its real broker and backends. */
|
||||
class CoreApplicationTest
|
||||
{
|
||||
@TempDir
|
||||
Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void bootsQueriesEveryTableAndProcessesOrderEvents() throws Exception
|
||||
{
|
||||
Integer port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
|
||||
QqqAllApplication.SyncOrderStep.reset();
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(dataDirectory, port))
|
||||
{
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpResponse<String> health = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:" + port + "/health")).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("\"UP\""));
|
||||
|
||||
HttpResponse<String> dashboard = client.send(HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://localhost:" + port + "/")).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, dashboard.statusCode());
|
||||
assertTrue(dashboard.body().contains("html"));
|
||||
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
MockAuthenticationModule mockAuth = new MockAuthenticationModule();
|
||||
assertEquals("core:admin", mockAuth.createSession(runtime.getLauncher().getQInstance(),
|
||||
Map.of("sessionId", DemoUsers.ADMIN_SESSION)).getUser().getIdReference());
|
||||
assertEquals("core:demo", mockAuth.createSession(runtime.getLauncher().getQInstance(),
|
||||
Map.of("sessionId", DemoUsers.DEMO_SESSION)).getUser().getIdReference());
|
||||
|
||||
for(String table : List.of("customer", "order", "orderLine", "product"))
|
||||
{
|
||||
assertFalse(new QueryAction().execute(new QueryInput(table)).getRecords().isEmpty(), table);
|
||||
}
|
||||
|
||||
waitFor(() -> QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents") != null
|
||||
&& QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents").getState() == EsbTriggerState.RUNNING);
|
||||
new InsertAction().execute(new InsertInput("order").withRecords(List.of(
|
||||
new QRecord().withValue("orderNo", "ORD-1002")
|
||||
.withValue("customerId", 2).withValue("status", "NEW"))));
|
||||
waitFor(() -> QqqAllApplication.SyncOrderStep.getRunCount() > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void waitFor(java.util.function.BooleanSupplier condition) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
|
||||
while(!condition.getAsBoolean() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertTrue(condition.getAsBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Exercises the shaded entry point and its HTTP API after packaging. */
|
||||
class CoreJarIT
|
||||
{
|
||||
@TempDir
|
||||
Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void packagedApplicationServesSeededData() throws Exception
|
||||
{
|
||||
Integer port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
|
||||
Path jar = Path.of(System.getProperty("qqqAllJar"));
|
||||
Path log = dataDirectory.resolve("app.log");
|
||||
ProcessBuilder builder = new ProcessBuilder(
|
||||
Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-jar", jar.toString());
|
||||
builder.environment().put("QQQ_ALL_PORT", port.toString());
|
||||
builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("data").toString());
|
||||
builder.redirectErrorStream(true).redirectOutput(log.toFile());
|
||||
Process process = builder.start();
|
||||
try
|
||||
{
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
URI healthUri = URI.create("http://127.0.0.1:" + port + "/health");
|
||||
HttpResponse<String> health = awaitHealth(client, healthUri, process, log);
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("\"UP\""));
|
||||
assertTrue(Files.readString(log).contains("{127.0.0.1:" + port + "}"),
|
||||
"Core profile must bind only to loopback");
|
||||
|
||||
HttpRequest query = HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/customer/query"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build();
|
||||
HttpResponse<String> customers = client.send(query, HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, customers.statusCode(), customers.body());
|
||||
assertTrue(customers.body().contains("Ada Lovelace"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.destroy();
|
||||
if(!process.waitFor(5, TimeUnit.SECONDS))
|
||||
{
|
||||
process.destroyForcibly();
|
||||
process.waitFor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponse<String> awaitHealth(HttpClient client, URI uri, Process process, Path log) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
|
||||
while(System.nanoTime() < deadline && process.isAlive())
|
||||
{
|
||||
try
|
||||
{
|
||||
return client.send(HttpRequest.newBuilder().uri(uri).build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
catch(java.net.ConnectException e)
|
||||
{
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Packaged app did not become healthy. Output:\n" + Files.readString(log));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user