fix(GH-21): follow listener address for the API catalog

This commit is contained in:
james.maes committed 2026-09-27 05:06:47 -05:00
1 parent 6e9419f342
commit a511834edd
6 files changed
+104 -6

No files matched your search

+4
View File
@@ -68,6 +68,10 @@ API key, script editor, or user-supplied code is needed.
| `calculateOrderTotal` | Executes fixed JavaScript through `ExecuteCodeAction`: quantity × unit price in cents → `totalCents`. It changes no records. | Admin only. |
| `demoNote` | Memory-backed table, seeded with `Transient demo note` on every start and cleared on shutdown. | Admin can edit; Demo/Viewer can read. |
The internal catalog URL follows `QQQ_ALL_BIND_HOST` and the configured HTTP port.
IPv4/IPv6 wildcard listeners use their corresponding loopback address; literal
IPv6 hosts are bracketed correctly in the URL.
The catalog endpoint exposes **only synthetic public demo data**. It is not an
outbound proxy and does not forward session credentials. The adapter supports
ascending ID order, limit/skip pagination, and one exact ID filter; unsupported
@@ -45,7 +45,7 @@ final class CoreRuntime implements AutoCloseable
Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath();
String brokerUrl = "tcp://127.0.0.1:" + brokerPort;
DemoOrderWebhookReceiver.clear();
DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl, true, port).defineQInstance(), port);
DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost).defineQInstance(), port);
Configuration configuration = new ConfigurationImpl()
.setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false)
.setBindingsDirectory(brokerDirectory.resolve("bindings").toString())
@@ -58,7 +58,7 @@ final class CoreRuntime implements AutoCloseable
try
{
QApplicationLauncher launcher = QApplicationLauncher.run(
new QqqAllApplication(dataDirectory, brokerUrl, true, port),
new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost),
new QApplicationLauncherConfig().withRegisterShutdownHook(false)
.withServerCustomizer(server -> server.withPort(port)
.withJavalinConfigCustomizer(config -> config.jetty.host = bindHost)));
@@ -6,7 +6,13 @@
*/
package com.kingsrook.qqq.all;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
@@ -33,10 +39,10 @@ final class DemoRuntimeFeatures
{
}
static void add(QInstance instance, int port)
static void add(QInstance instance, int port, String bindHost) throws QException
{
instance.addBackend(new APIBackendMetaData().withName("demoApi")
.withBaseUrl("http://127.0.0.1:" + port + "/demo")
.withBaseUrl(catalogBaseUrl(bindHost, port))
.withAuthorizationType(AuthorizationType.NONE).withContentType("application/json")
.withActionUtil(new QCodeReference(DemoCatalogApi.class)));
instance.addTable(new QTableMetaData().withName("apiCatalog").withLabel("API Catalog")
@@ -65,4 +71,20 @@ final class DemoRuntimeFeatures
new QFieldMetaData("totalCents", QFieldType.INTEGER))))));
QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(new QCodeReference(DemoCatalogRoutes.class));
}
private static String catalogBaseUrl(String bindHost, int port) throws QException
{
try
{
InetAddress address = InetAddress.getByName(bindHost);
String host = address.isAnyLocalAddress()
? (address instanceof Inet6Address ? "::1" : "127.0.0.1") : bindHost;
return new URI("http", null, host, port, "/demo", null, null).toASCIIString();
}
catch(UnknownHostException | URISyntaxException e)
{
throw new QException("Could not derive demo catalog URL from HTTP bind host", e);
}
}
}
@@ -67,7 +67,7 @@ final class FullProfileApplication extends AbstractQQQApplication
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false, settings.port("PORT", "8080")).defineQInstance();
QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false, settings.port("PORT", "8080"), settings.get("BIND_HOST")).defineQInstance();
addBackends(instance);
addTables(instance);
addAuthentication(instance);
@@ -59,6 +59,7 @@ public final class QqqAllApplication extends AbstractQQQApplication
private final String brokerUrl;
private final boolean includeDemoQbits;
private final int httpPort;
private final String bindHost;
public QqqAllApplication(Path dataDirectory, String brokerUrl)
{
@@ -72,6 +73,12 @@ public final class QqqAllApplication extends AbstractQQQApplication
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort)
{
this(dataDirectory, brokerUrl, includeDemoQbits, httpPort, "127.0.0.1");
}
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort, String bindHost)
{
this.bindHost = bindHost;
this.httpPort = httpPort;
this.dataDirectory = dataDirectory;
this.brokerUrl = brokerUrl;
@@ -159,7 +166,7 @@ public final class QqqAllApplication extends AbstractQQQApplication
QJavalinMetaData.ofOrWithNew(instance)
.withAdditionalRouteProviderReference(new QCodeReference(DemoOrderWebhookReceiver.class));
}
DemoRuntimeFeatures.add(instance, httpPort);
DemoRuntimeFeatures.add(instance, httpPort, bindHost);
return instance;
}
@@ -16,6 +16,8 @@ import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -101,6 +103,69 @@ class CoreJarIT
}
}
@ParameterizedTest
@ValueSource(strings = {"::1", "::", "0:0:0:0:0:0:0:0", "0.0.0.0"})
void packagedCatalogUsesConfiguredListener(String bindHost) throws Exception
{
String clientHost = bindHost.contains(":") ? "::1" : "127.0.0.1";
int port;
try(ServerSocket socket = new ServerSocket(0, 50, java.net.InetAddress.getByName(clientHost)))
{
port = socket.getLocalPort();
}
URI base = new URI("http", null, clientHost, port, "/", null, null);
Path log = dataDirectory.resolve("bound-app.log");
ProcessBuilder builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(),
"-jar", System.getProperty("qqqAllJar"));
builder.environment().put("QQQ_ALL_PROFILE", "core");
builder.environment().put("QQQ_ALL_BIND_HOST", bindHost);
builder.environment().put("QQQ_ALL_PORT", Integer.toString(port));
builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("bound-data").toString());
builder.redirectErrorStream(true).redirectOutput(log.toFile());
Process process = builder.start();
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build())
{
assertEquals(200, awaitHealth(client, base.resolve("health"), process, log).statusCode());
var direct = client.send(HttpRequest.newBuilder(base.resolve("demo/catalog"))
.timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, direct.statusCode(), "Configured listener must serve its catalog before testing the backend");
assertEquals(2, new org.json.JSONArray(direct.body()).length());
var all = catalogQuery(client, base, "{}");
assertEquals(java.util.List.of(101, 102, 103), all.toList().stream()
.map(row -> ((java.util.Map<?, ?>) ((java.util.Map<?, ?>) row).get("values")).get("id")).toList());
for(int skip = 0; skip < 3; skip++)
{
var page = catalogQuery(client, base, "{\"filter\":{\"skip\":" + skip + ",\"limit\":1}}");
assertEquals(1, page.length());
assertEquals(java.util.List.of(101, 102, 103).get(skip), page.getJSONObject(0).getJSONObject("values").getInt("id"));
}
assertEquals(0, catalogQuery(client, base, "{\"filter\":{\"skip\":3,\"limit\":1}}").length());
var get = client.send(HttpRequest.newBuilder(base.resolve("data/apiCatalog/103"))
.timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, get.statusCode(), get.body());
assertEquals("Shipping Box", new org.json.JSONObject(get.body()).getJSONObject("values").getString("name"));
}
finally
{
process.destroy();
if(!process.waitFor(5, TimeUnit.SECONDS))
{
process.destroyForcibly();
assertTrue(process.waitFor(5, TimeUnit.SECONDS), "Owned packaged process must stop");
}
}
}
private static org.json.JSONArray catalogQuery(HttpClient client, URI base, String body) throws Exception
{
var response = client.send(HttpRequest.newBuilder(base.resolve("qqq/v1/table/apiCatalog/query"))
.timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode(), response.body());
return new org.json.JSONObject(response.body()).getJSONArray("records");
}
private static HttpResponse<String> awaitHealth(HttpClient client, URI uri, Process process, Path log) throws Exception
{
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();