diff --git a/exist-core/src/test/resources/openapi-demo/controller.json b/exist-core/src/test/resources/openapi-demo/controller.json
new file mode 100644
index 00000000000..2886cefc635
--- /dev/null
+++ b/exist-core/src/test/resources/openapi-demo/controller.json
@@ -0,0 +1,15 @@
+{
+ "version": "1.0",
+ "apis": [
+ {
+ "spec": "modules/api.json",
+ "priority": "1"
+ }
+ ],
+ "routes": {
+ "/": { "redirect": "index.html" }
+ },
+ "cors": {
+ "allow-origin": "*"
+ }
+}
diff --git a/exist-core/src/test/resources/openapi-demo/modules/api.json b/exist-core/src/test/resources/openapi-demo/modules/api.json
new file mode 100644
index 00000000000..8ac4ec6e471
--- /dev/null
+++ b/exist-core/src/test/resources/openapi-demo/modules/api.json
@@ -0,0 +1,58 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "OpenAPI Demo",
+ "version": "1.0.0",
+ "description": "Minimal demo of eXist-db's built-in OpenAPI routing — no Roaster, no controller.xql"
+ },
+ "paths": {
+ "/api/hello": {
+ "get": {
+ "summary": "Say hello",
+ "operationId": "hello:greet",
+ "responses": {
+ "200": {
+ "description": "Greeting message"
+ }
+ }
+ }
+ },
+ "/api/hello/{name}": {
+ "get": {
+ "summary": "Say hello to someone",
+ "operationId": "hello:greet-name",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Personalized greeting"
+ }
+ }
+ }
+ },
+ "/api/echo": {
+ "post": {
+ "summary": "Echo back the request body",
+ "operationId": "hello:echo",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": { "type": "object" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Echoed request"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/exist-core/src/test/resources/openapi-demo/modules/hello.xqm b/exist-core/src/test/resources/openapi-demo/modules/hello.xqm
new file mode 100644
index 00000000000..3609c6d5808
--- /dev/null
+++ b/exist-core/src/test/resources/openapi-demo/modules/hello.xqm
@@ -0,0 +1,76 @@
+(:
+ : eXist-db Open Source Native XML Database
+ : Copyright (C) 2001 The eXist-db Authors
+ :
+ : info@exist-db.org
+ : http://www.exist-db.org
+ :
+ : This library is free software; you can redistribute it and/or
+ : modify it under the terms of the GNU Lesser General Public
+ : License as published by the Free Software Foundation; either
+ : version 2.1 of the License, or (at your option) any later version.
+ :
+ : This library is distributed in the hope that it will be useful,
+ : but WITHOUT ANY WARRANTY; without even the implied warranty of
+ : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ : Lesser General Public License for more details.
+ :
+ : You should have received a copy of the GNU Lesser General Public
+ : License along with this library; if not, write to the Free Software
+ : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ :)
+xquery version "3.1";
+
+(:~
+ : Demo handler module for OpenAPI-native routing.
+ :
+ : No Roaster import. No controller.xql. Just a plain XQuery module
+ : with functions that accept a $request map and return data.
+ :
+ : The module namespace URI follows the convention:
+ : http://exist-db.org/apps/{prefix}
+ : where {prefix} matches the operationId prefix in api.json.
+ :)
+module namespace hello = "http://exist-db.org/apps/hello";
+
+declare namespace output = "http://www.w3.org/2010/xslt-xquery-serialization";
+declare option output:method "json";
+declare option output:media-type "application/json";
+
+(:~
+ : Simple greeting.
+ : GET /api/hello
+ :)
+declare function hello:greet($request as map(*)) {
+ map {
+ "message": "Hello from eXist-db's built-in OpenAPI routing!",
+ "method": $request?method,
+ "path": $request?path,
+ "note": "No Roaster. No controller.xql. Just controller.json + api.json + this module."
+ }
+};
+
+(:~
+ : Personalized greeting.
+ : GET /api/hello/{name}
+ :)
+declare function hello:greet-name($request as map(*)) {
+ let $name := $request?parameters?name
+ return map {
+ "message": "Hello, " || $name || "!",
+ "parameters": $request?parameters
+ }
+};
+
+(:~
+ : Echo the request.
+ : POST /api/echo
+ :)
+declare function hello:echo($request as map(*)) {
+ map {
+ "echo": true(),
+ "method": $request?method,
+ "body": $request?body,
+ "parameters": $request?parameters
+ }
+};
diff --git a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml
index 76b81502392..941e0e04f7d 100644
--- a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml
+++ b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml
@@ -26,6 +26,7 @@
+
diff --git a/exist-distribution/pom.xml b/exist-distribution/pom.xml
index 2369cda5832..10ceb64405c 100644
--- a/exist-distribution/pom.xml
+++ b/exist-distribution/pom.xml
@@ -64,6 +64,12 @@
${project.version}
runtime
+
+ ${project.groupId}
+ exist-services
+ ${project.version}
+ runtime
+
${project.groupId}
exist-start
diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml
index 6a9937c0e03..8d139208870 100644
--- a/exist-distribution/src/main/config/conf.xml
+++ b/exist-distribution/src/main/config/conf.xml
@@ -216,6 +216,12 @@
EXQuery RESTXQ trigger to load the RESTXQ Registry at startup time
-->
+
+
+
+
+
+
diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml
index 4bb83a9f548..e80a2f5db18 100644
--- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml
+++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml
@@ -28,6 +28,12 @@
-->
+
+
+
+
+
+
diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml
index dc9885872ca..690f96613fc 100644
--- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml
+++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml
@@ -179,6 +179,38 @@
4
+
+
+ PackageManagementServlet
+ org.exist.http.servlets.PackageManagementServlet
+
+ use-default-user
+ false
+
+
+ 268435456
+ 268435456
+
+ 3
+
+
+
+
+ OpenApiServlet
+ org.exist.http.openapi.OpenApiServlet
+
+ use-default-user
+ false
+
+ 3
+
+
diff --git a/exist-services/pom.xml b/exist-services/pom.xml
new file mode 100644
index 00000000000..054e1ba66fe
--- /dev/null
+++ b/exist-services/pom.xml
@@ -0,0 +1,38 @@
+
+
+ 4.0.0
+
+
+ org.exist-db
+ exist-parent
+ 7.0.0-SNAPSHOT
+ ../exist-parent
+
+
+ exist-services
+ jar
+
+ eXist-db Platform Services
+
+ Built-in HTTP services for the eXist-db platform: package management REST API
+ and OpenAPI-based routing. These services are compiled into the platform and
+ require no XAR package installation.
+
+
+
+
+
+ org.exist-db
+ exist-core
+ ${project.version}
+
+
+
+
+ org.apache.logging.log4j
+ log4j-api
+
+
+
diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiServiceRegistry.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServiceRegistry.java
new file mode 100644
index 00000000000..5241c7466e1
--- /dev/null
+++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServiceRegistry.java
@@ -0,0 +1,246 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.openapi;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import javax.annotation.Nullable;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * In-memory registry of OpenAPI routes discovered from api.json and controller.json
+ * files stored in the database. Routes are scoped by collection path.
+ *
+ * Thread-safe: uses ConcurrentHashMap for route storage.
+ *
+ */
+public class OpenApiServiceRegistry {
+
+ private static final Logger LOG = LogManager.getLogger(OpenApiServiceRegistry.class);
+
+ private static volatile OpenApiServiceRegistry instance;
+
+ /** All registered routes, keyed by collection path */
+ private final ConcurrentHashMap> routesByCollection = new ConcurrentHashMap<>();
+
+ public static OpenApiServiceRegistry getInstance() {
+ if (instance == null) {
+ synchronized (OpenApiServiceRegistry.class) {
+ if (instance == null) {
+ instance = new OpenApiServiceRegistry();
+ }
+ }
+ }
+ return instance;
+ }
+
+ /**
+ * Register routes from an OpenAPI spec for a given collection.
+ *
+ * @param collectionPath the database collection path (e.g., "/db/apps/myapp")
+ * @param routes the routes parsed from the spec
+ */
+ public void registerRoutes(final String collectionPath, final List routes) {
+ routesByCollection.put(collectionPath, new ArrayList<>(routes));
+ LOG.info("Registered {} OpenAPI routes for collection {}", routes.size(), collectionPath);
+ }
+
+ /**
+ * Deregister all routes for a collection.
+ */
+ public void deregisterRoutes(final String collectionPath) {
+ final List removed = routesByCollection.remove(collectionPath);
+ if (removed != null) {
+ LOG.info("Deregistered {} OpenAPI routes for collection {}", removed.size(), collectionPath);
+ }
+ }
+
+ /**
+ * Find a matching route for the given HTTP method and path.
+ * The path should be relative to /exist/apps/ (e.g., "myapp/api/users/123").
+ *
+ * @param method HTTP method (GET, POST, etc.)
+ * @param path request path relative to /exist/apps/
+ * @return matched route with extracted path parameters, or null if no match
+ */
+ @Nullable
+ public RouteMatch findRoute(final String method, final String path) {
+ // Extract app name from path (first segment)
+ final int slashIdx = path.indexOf('/');
+ final String appName = slashIdx > 0 ? path.substring(0, slashIdx) : path;
+ final String remainingPath = slashIdx > 0 ? path.substring(slashIdx) : "/";
+
+ // Search for matching collection
+ for (final Map.Entry> entry : routesByCollection.entrySet()) {
+ final String collectionPath = entry.getKey();
+ // Match by app name (last segment of collection path)
+ final String collAppName = collectionPath.substring(collectionPath.lastIndexOf('/') + 1);
+ if (!collAppName.equals(appName)) {
+ continue;
+ }
+
+ // Search routes in this collection
+ RouteMatch bestMatch = null;
+ for (final Route route : entry.getValue()) {
+ if (!route.method().equalsIgnoreCase(method)) {
+ continue;
+ }
+ final Map params = matchPath(route.pathPattern(), route.pathRegex(), remainingPath);
+ if (params != null) {
+ final RouteMatch match = new RouteMatch(route, collectionPath, params);
+ // Prefer more specific routes (longer pattern without variables)
+ if (bestMatch == null || match.specificity() > bestMatch.specificity()) {
+ bestMatch = match;
+ }
+ }
+ }
+ if (bestMatch != null) {
+ return bestMatch;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Check if any routes are registered.
+ */
+ public boolean hasRoutes() {
+ return !routesByCollection.isEmpty();
+ }
+
+ /**
+ * Get all registered collection paths.
+ */
+ public Set getRegisteredCollections() {
+ return routesByCollection.keySet();
+ }
+
+ // --- Path matching ---
+
+ /**
+ * Match a request path against a route's compiled regex.
+ * Returns parameter map if matched, null otherwise.
+ */
+ @Nullable
+ private Map matchPath(final String pattern, final Pattern regex, final String path) {
+ final Matcher matcher = regex.matcher(path);
+ if (!matcher.matches()) {
+ return null;
+ }
+ final Map params = new LinkedHashMap<>();
+ // Extract named groups from the pattern
+ final List paramNames = extractParamNames(pattern);
+ for (int i = 0; i < paramNames.size(); i++) {
+ if (i + 1 <= matcher.groupCount()) {
+ params.put(paramNames.get(i), matcher.group(i + 1));
+ }
+ }
+ return params;
+ }
+
+ // --- Static helpers for route compilation ---
+
+ /**
+ * Compile an OpenAPI path pattern (e.g., "/users/{id}") into a regex.
+ */
+ public static Pattern compilePathPattern(final String pattern) {
+ final StringBuilder regex = new StringBuilder("^");
+ int i = 0;
+ while (i < pattern.length()) {
+ final char c = pattern.charAt(i);
+ if (c == '{') {
+ final int end = pattern.indexOf('}', i);
+ if (end > 0) {
+ regex.append("([^/]+)");
+ i = end + 1;
+ continue;
+ }
+ }
+ // Escape regex special characters
+ if (".+*?^$[]()\\|".indexOf(c) >= 0) {
+ regex.append('\\');
+ }
+ regex.append(c);
+ i++;
+ }
+ regex.append("$");
+ return Pattern.compile(regex.toString());
+ }
+
+ /**
+ * Extract parameter names from an OpenAPI path pattern.
+ */
+ public static List extractParamNames(final String pattern) {
+ final List names = new ArrayList<>();
+ int i = 0;
+ while (i < pattern.length()) {
+ if (pattern.charAt(i) == '{') {
+ final int end = pattern.indexOf('}', i);
+ if (end > 0) {
+ names.add(pattern.substring(i + 1, end));
+ i = end + 1;
+ continue;
+ }
+ }
+ i++;
+ }
+ return names;
+ }
+
+ // --- Inner types ---
+
+ /**
+ * A registered route from an OpenAPI spec.
+ */
+ public record Route(
+ String method, // HTTP method (GET, POST, etc.)
+ String pathPattern, // original pattern, e.g. "/api/users/{id}"
+ Pattern pathRegex, // compiled regex
+ String operationId, // e.g. "users:get"
+ Map spec // the full operation spec from api.json
+ ) {
+ /**
+ * Specificity score: how specific is this route? Higher = more specific.
+ * Literal segments score higher than parameterized ones.
+ */
+ public int specificity() {
+ return pathPattern.replaceAll("\\{[^}]+}", "").length();
+ }
+ }
+
+ /**
+ * A matched route with extracted path parameters.
+ */
+ public record RouteMatch(
+ Route route,
+ String collectionPath,
+ Map pathParams
+ ) {
+ public int specificity() {
+ return route.specificity();
+ }
+ }
+}
diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiServlet.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServlet.java
new file mode 100644
index 00000000000..d85bc40acda
--- /dev/null
+++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiServlet.java
@@ -0,0 +1,382 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.openapi;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.exist.EXistException;
+import org.exist.http.servlets.AbstractExistHttpServlet;
+import org.exist.security.Subject;
+import org.exist.source.StringSource;
+import org.exist.storage.DBBroker;
+import org.exist.storage.XQueryPool;
+import org.exist.util.serializer.XQuerySerializer;
+import org.exist.xmldb.XmldbURI;
+import org.exist.xquery.*;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.value.*;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * Built-in OpenAPI routing servlet. When a collection contains a {@code controller.json}
+ * that declares OpenAPI spec files, this servlet handles routing requests to XQuery
+ * handler functions based on the OpenAPI spec's operationId mapping.
+ *
+ * This replaces the Roaster XQuery library for apps that opt in via controller.json.
+ * Apps without controller.json continue to use XQueryURLRewrite + controller.xql.
+ *
+ *
+ * For a request to {@code /exist/apps/myapp/api/users/123}:
+ *
+ * - Extract app name "myapp" and remaining path "/api/users/123"
+ * - Look up matching route in OpenApiServiceRegistry
+ * - If found: build request map, compile handler XQuery, execute, serialize result
+ * - If not found: return false to let the filter chain continue (falls through to XQueryURLRewrite)
+ *
+ *
+ */
+public class OpenApiServlet extends AbstractExistHttpServlet {
+
+ private static final Logger LOG = LogManager.getLogger(OpenApiServlet.class);
+
+ @Override
+ public Logger getLog() {
+ return LOG;
+ }
+
+ @Override
+ protected void service(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+
+ final OpenApiServiceRegistry registry = OpenApiServiceRegistry.getInstance();
+ if (!registry.hasRoutes()) {
+ // No routes registered, pass through
+ super.service(request, response);
+ return;
+ }
+
+ // Extract the path relative to /exist/openapi/
+ final String requestURI = request.getRequestURI();
+ final String openApiPrefix = request.getContextPath() + "/openapi/";
+ if (!requestURI.startsWith(openApiPrefix)) {
+ super.service(request, response);
+ return;
+ }
+ final String relativePath = requestURI.substring(openApiPrefix.length());
+ final String method = request.getMethod();
+
+ // Handle CORS preflight
+ if ("OPTIONS".equalsIgnoreCase(method)) {
+ handleOptions(request, response);
+ return;
+ }
+
+ // Try to match a route
+ final OpenApiServiceRegistry.RouteMatch match = registry.findRoute(method, relativePath);
+ if (match == null) {
+ // No matching route — fall through to default handler (XQueryURLRewrite)
+ super.service(request, response);
+ return;
+ }
+
+ // Authenticate
+ final Subject user = authenticate(request, response);
+ if (user == null) {
+ return;
+ }
+
+ // Dispatch to XQuery handler
+ try (final DBBroker broker = getPool().get(Optional.of(user))) {
+ dispatchToHandler(broker, request, response, match);
+ } catch (final EXistException e) {
+ LOG.error("Database error dispatching OpenAPI request", e);
+ writeJsonError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
+ } catch (final XPathException e) {
+ LOG.error("XQuery error dispatching OpenAPI request", e);
+ writeJsonError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
+ } catch (final org.exist.security.PermissionDeniedException e) {
+ LOG.error("Permission denied dispatching OpenAPI request", e);
+ writeJsonError(response, HttpServletResponse.SC_FORBIDDEN, e.getMessage());
+ }
+ }
+
+ /**
+ * Dispatch a matched route to its XQuery handler function.
+ */
+ private void dispatchToHandler(final DBBroker broker, final HttpServletRequest request,
+ final HttpServletResponse response,
+ final OpenApiServiceRegistry.RouteMatch match)
+ throws XPathException, IOException, EXistException, org.exist.security.PermissionDeniedException {
+
+ final OpenApiServiceRegistry.Route route = match.route();
+ final String operationId = route.operationId();
+ final String collectionPath = match.collectionPath();
+
+ // Build the $request map (compatible with Roaster's format)
+ final MapType requestMap = buildRequestMap(broker, request, match);
+
+ // Build XQuery that resolves the operationId and calls the function
+ // The operationId is expected to be in prefix:local-name format
+ final String xquery = buildHandlerXQuery(operationId, collectionPath);
+
+ // Compile and execute
+ final XQuery xqueryService = broker.getBrokerPool().getXQueryService();
+ final XQueryContext context = new XQueryContext(broker.getBrokerPool());
+ context.setModuleLoadPath(XmldbURI.EMBEDDED_SERVER_URI_PREFIX + collectionPath + "/modules");
+
+ // Declare the $request external variable
+ context.declareVariable("request", requestMap);
+
+ final StringSource source = new StringSource(xquery);
+ final CompiledXQuery compiled;
+ try {
+ compiled = xqueryService.compile(context, source);
+ } catch (final IOException e) {
+ throw new XPathException((Expression) null, "Failed to compile handler for " + operationId + ": " + e.getMessage());
+ }
+
+ final Properties outputProperties = new Properties();
+ final Sequence result;
+ try {
+ result = xqueryService.execute(broker, compiled, null, outputProperties);
+ } finally {
+ context.runCleanupTasks();
+ }
+
+ // Serialize the result
+ serializeResult(broker, response, result, route, outputProperties);
+ }
+
+ /**
+ * Build the XQuery source that resolves an operationId to a function and calls it.
+ * The operationId format is "prefix:local-name" (e.g., "hello:greet").
+ * The handler module is expected to be in the collection's modules/ directory.
+ */
+ private String buildHandlerXQuery(final String operationId, final String collectionPath) {
+ // Parse prefix:local-name
+ final int colonIdx = operationId.indexOf(':');
+ if (colonIdx < 0) {
+ // No prefix — treat as a local function name
+ return "declare variable $request external;\n" +
+ "let $fn := function-lookup(xs:QName('" + operationId + "'), 1)\n" +
+ "return if (exists($fn)) then $fn($request) else error(xs:QName('err:HANDLER'), 'Handler not found: " + operationId + "')";
+ }
+
+ final String prefix = operationId.substring(0, colonIdx);
+ final String localName = operationId.substring(colonIdx + 1);
+
+ // Scan for XQuery modules in the collection to find the right namespace
+ // For the PoC, we use a convention: the module file is named {prefix}.xqm
+ // and stored in the modules/ subdirectory
+ return "xquery version \"3.1\";\n" +
+ "import module namespace " + prefix + " = \"http://exist-db.org/apps/" + prefix + "\" " +
+ "at \"" + prefix + ".xqm\";\n" +
+ "declare variable $request external;\n" +
+ prefix + ":" + localName + "($request)";
+ }
+
+ /**
+ * Build the request map passed to handler functions.
+ * Shape is compatible with Roaster's $request map.
+ */
+ private MapType buildRequestMap(final DBBroker broker, final HttpServletRequest request,
+ final OpenApiServiceRegistry.RouteMatch match)
+ throws XPathException {
+
+ final XQueryContext tempContext = new XQueryContext(broker.getBrokerPool());
+ final MapType requestMap = new MapType(tempContext);
+
+ // Method
+ requestMap.add(new StringValue("method"), new StringValue(request.getMethod().toLowerCase()));
+
+ // Path
+ requestMap.add(new StringValue("path"), new StringValue(request.getRequestURI()));
+
+ // Pattern
+ requestMap.add(new StringValue("pattern"), new StringValue(match.route().pathPattern()));
+
+ // Path parameters
+ final MapType paramsMap = new MapType(tempContext);
+ for (final Map.Entry param : match.pathParams().entrySet()) {
+ paramsMap.add(new StringValue(param.getKey()), new StringValue(param.getValue()));
+ }
+ // Also add query parameters
+ for (final Map.Entry param : request.getParameterMap().entrySet()) {
+ if (param.getValue().length > 0) {
+ paramsMap.add(new StringValue(param.getKey()), new StringValue(param.getValue()[0]));
+ }
+ }
+ requestMap.add(new StringValue("parameters"), paramsMap);
+
+ // Request body (for POST/PUT/PATCH)
+ final String contentType = request.getContentType();
+ if (contentType != null && request.getContentLength() > 0) {
+ try {
+ final String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ if (contentType.contains("json")) {
+ // Store raw JSON string — handler can parse with parse-json()
+ requestMap.add(new StringValue("body"), new StringValue(body));
+ } else {
+ requestMap.add(new StringValue("body"), new StringValue(body));
+ }
+ } catch (final IOException e) {
+ LOG.debug("Failed to read request body", e);
+ }
+ }
+
+ return requestMap;
+ }
+
+ /**
+ * Serialize the XQuery result to the HTTP response.
+ */
+ private void serializeResult(final DBBroker broker, final HttpServletResponse response,
+ final Sequence result,
+ final OpenApiServiceRegistry.Route route,
+ final Properties outputProperties) throws IOException {
+
+ if (result.isEmpty()) {
+ response.setStatus(HttpServletResponse.SC_NO_CONTENT);
+ return;
+ }
+
+ // Check if the result is a Roaster-style response map (with "code" and/or "body" keys)
+ if (result.getItemCount() == 1 && result.itemAt(0).getType() == Type.MAP_ITEM) {
+ try {
+ final MapType responseMap = (MapType) result.itemAt(0);
+ // Only treat as response map if it has "code" or "body" keys
+ final Sequence codeSeq = responseMap.get(new StringValue("code"));
+ final Sequence bodySeq = responseMap.get(new StringValue("body"));
+ if ((codeSeq != null && !codeSeq.isEmpty()) || (bodySeq != null && !bodySeq.isEmpty())) {
+ serializeResponseMap(broker, response, responseMap, outputProperties);
+ return;
+ }
+ } catch (final XPathException e) {
+ LOG.debug("Failed to interpret result as response map, falling back to default serialization", e);
+ }
+ }
+
+ // Default: serialize as JSON using the adaptive/JSON serializer
+ response.setContentType("application/json");
+ response.setCharacterEncoding("UTF-8");
+ response.setStatus(HttpServletResponse.SC_OK);
+
+ outputProperties.setProperty("method", "json");
+ outputProperties.setProperty("media-type", "application/json");
+
+ try (final OutputStream os = response.getOutputStream();
+ final OutputStreamWriter writer = new OutputStreamWriter(os, StandardCharsets.UTF_8);
+ final PrintWriter pw = new PrintWriter(writer)) {
+ final XQuerySerializer serializer = new XQuerySerializer(broker, outputProperties, pw);
+ serializer.serialize(result);
+ pw.flush();
+ } catch (final Exception e) {
+ LOG.error("Failed to serialize result", e);
+ writeJsonError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Serialization error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Handle a response map returned by the handler.
+ * The map may contain keys: "code" (integer), "type" (media-type string),
+ * "body" (the response body), "headers" (map of header name → value).
+ * This is compatible with Roaster's roaster:response() format.
+ */
+ private void serializeResponseMap(final DBBroker broker, final HttpServletResponse response,
+ final MapType responseMap, final Properties outputProperties)
+ throws XPathException, IOException {
+
+ // Status code
+ int statusCode = HttpServletResponse.SC_OK;
+ final Sequence codeSeq = responseMap.get(new StringValue("code"));
+ if (codeSeq != null && !codeSeq.isEmpty()) {
+ statusCode = ((IntegerValue) codeSeq.itemAt(0)).getInt();
+ }
+
+ // Content type
+ String contentType = "application/json";
+ final Sequence typeSeq = responseMap.get(new StringValue("type"));
+ if (typeSeq != null && !typeSeq.isEmpty()) {
+ contentType = typeSeq.itemAt(0).getStringValue();
+ }
+
+ // Headers (simplified for PoC — skip custom headers from response map)
+
+ // Body
+ final Sequence bodySeq = responseMap.get(new StringValue("body"));
+
+ response.setContentType(contentType);
+ response.setCharacterEncoding("UTF-8");
+ response.setStatus(statusCode);
+
+ if (bodySeq != null && !bodySeq.isEmpty()) {
+ try (final OutputStream os = response.getOutputStream();
+ final OutputStreamWriter writer = new OutputStreamWriter(os, StandardCharsets.UTF_8);
+ final PrintWriter pw = new PrintWriter(writer)) {
+ if (contentType.contains("json")) {
+ outputProperties.setProperty("method", "json");
+ outputProperties.setProperty("media-type", "application/json");
+ } else if (contentType.contains("xml")) {
+ outputProperties.setProperty("method", "xml");
+ } else if (contentType.contains("html")) {
+ outputProperties.setProperty("method", "html5");
+ }
+ final XQuerySerializer serializer = new XQuerySerializer(broker, outputProperties, pw);
+ serializer.serialize(bodySeq);
+ } catch (final Exception e) {
+ LOG.error("Failed to serialize response body", e);
+ }
+ }
+ }
+
+ private void handleOptions(final HttpServletRequest request, final HttpServletResponse response) {
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+ response.setStatus(HttpServletResponse.SC_OK);
+ }
+
+ private void writeJsonError(final HttpServletResponse response, final int status, final String message)
+ throws IOException {
+ response.setContentType("application/json");
+ response.setStatus(status);
+ try (final OutputStream os = response.getOutputStream()) {
+ final com.fasterxml.jackson.core.JsonFactory factory = new com.fasterxml.jackson.core.JsonFactory();
+ try (final JsonGenerator gen = factory.createGenerator(os)) {
+ gen.writeStartObject();
+ gen.writeStringField("status", "error");
+ gen.writeStringField("message", message);
+ gen.writeEndObject();
+ }
+ }
+ }
+}
diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiStartupTrigger.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiStartupTrigger.java
new file mode 100644
index 00000000000..b467d1e3ed0
--- /dev/null
+++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiStartupTrigger.java
@@ -0,0 +1,146 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.openapi;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.exist.collections.Collection;
+import org.exist.dom.persistent.BinaryDocument;
+import org.exist.dom.persistent.DocumentImpl;
+import org.exist.storage.DBBroker;
+import org.exist.storage.StartupTrigger;
+import org.exist.storage.txn.Txn;
+import org.exist.xmldb.XmldbURI;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * Startup trigger that scans {@code /db/apps/} for collections containing
+ * {@code controller.json} or {@code api.json} files and registers their
+ * OpenAPI routes with the {@link OpenApiServiceRegistry}.
+ *
+ * Configure in conf.xml:
+ *
+ * <trigger class="org.exist.http.openapi.OpenApiStartupTrigger"/>
+ *
+ *
+ */
+public class OpenApiStartupTrigger implements StartupTrigger {
+
+ private static final Logger LOG = LogManager.getLogger(OpenApiStartupTrigger.class);
+
+ @Override
+ public void execute(final DBBroker sysBroker, final Txn transaction,
+ final Map> params) {
+ LOG.info("OpenAPI startup trigger: scanning /db/apps/ for controller.json files...");
+
+ try {
+ final XmldbURI appsUri = XmldbURI.create("/db/apps");
+ final Collection appsCollection = sysBroker.getCollection(appsUri);
+ if (appsCollection == null) {
+ LOG.info("No /db/apps/ collection found, skipping OpenAPI scan");
+ return;
+ }
+
+ int routeCount = 0;
+ for (final Iterator it = appsCollection.collectionIterator(sysBroker); it.hasNext(); ) {
+ final XmldbURI childName = it.next();
+ final XmldbURI childUri = appsUri.append(childName);
+ final Collection childColl = sysBroker.getCollection(childUri);
+ if (childColl == null) {
+ continue;
+ }
+
+ // Check for controller.json
+ final DocumentImpl controllerDoc = childColl.getDocument(sysBroker,
+ XmldbURI.create("controller.json"));
+ if (controllerDoc != null && controllerDoc instanceof BinaryDocument) {
+ try {
+ final String json = readBinaryDocument(sysBroker, (BinaryDocument) controllerDoc);
+ final List