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}: + *

    + *
  1. Extract app name "myapp" and remaining path "/api/users/123"
  2. + *
  3. Look up matching route in OpenApiServiceRegistry
  4. + *
  5. If found: build request map, compile handler XQuery, execute, serialize result
  6. + *
  7. If not found: return false to let the filter chain continue (falls through to XQueryURLRewrite)
  8. + *
+ *

+ */ +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> apis = OpenApiTrigger.parseApisArray(json); + + final List allRoutes = new ArrayList<>(); + for (final Map api : apis) { + final String specPath = api.get("spec"); + if (specPath == null) continue; + final String prefix = api.get("prefix"); + + // Resolve spec relative to app collection + final XmldbURI specUri = childUri.append(specPath); + final DocumentImpl specDoc = sysBroker.getResource(specUri, org.exist.security.Permission.READ); + if (specDoc == null) { + LOG.warn("OpenAPI spec not found: {}", specUri); + continue; + } + final String specJson = readBinaryDocument(sysBroker, (BinaryDocument) specDoc); + allRoutes.addAll(OpenApiTrigger.parseOpenApiSpec(specJson, prefix)); + } + + if (!allRoutes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes( + childUri.getCollectionPath(), allRoutes); + routeCount += allRoutes.size(); + } + } catch (final Exception e) { + LOG.error("Failed to process controller.json in {}: {}", + childUri, e.getMessage(), e); + } + continue; + } + + // Check for standalone api.json + final DocumentImpl apiDoc = childColl.getDocument(sysBroker, + XmldbURI.create("api.json")); + if (apiDoc != null && apiDoc instanceof BinaryDocument) { + try { + final String json = readBinaryDocument(sysBroker, (BinaryDocument) apiDoc); + final List routes = + OpenApiTrigger.parseOpenApiSpec(json, null); + if (!routes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes( + childUri.getCollectionPath(), routes); + routeCount += routes.size(); + } + } catch (final Exception e) { + LOG.error("Failed to process api.json in {}: {}", + childUri, e.getMessage(), e); + } + } + } + + LOG.info("OpenAPI startup trigger: registered {} routes", routeCount); + + } catch (final Exception e) { + LOG.error("OpenAPI startup trigger failed: {}", e.getMessage(), e); + } + } + + private String readBinaryDocument(final DBBroker broker, final BinaryDocument document) + throws IOException { + try (final InputStream is = broker.getBinaryResource(document)) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/exist-services/src/main/java/org/exist/http/openapi/OpenApiTrigger.java b/exist-services/src/main/java/org/exist/http/openapi/OpenApiTrigger.java new file mode 100644 index 00000000000..e86c678e44c --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/openapi/OpenApiTrigger.java @@ -0,0 +1,332 @@ +/* + * 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.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.collections.triggers.SAXTrigger; +import org.exist.collections.triggers.TriggerException; +import org.exist.dom.persistent.BinaryDocument; +import org.exist.dom.persistent.DocumentImpl; +import org.exist.storage.DBBroker; +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.*; +import java.util.regex.Pattern; + +/** + * Collection trigger that watches for {@code controller.json} and {@code api.json} + * files being stored in the database. When found, parses the OpenAPI spec(s) and + * registers routes with the {@link OpenApiServiceRegistry}. + *

+ * Configure in conf.xml: + *

+ * <trigger class="org.exist.http.openapi.OpenApiTrigger"/>
+ * 
+ *

+ */ +public class OpenApiTrigger extends SAXTrigger { + + private static final Logger LOG = LogManager.getLogger(OpenApiTrigger.class); + + @Override + public void beforeCreateDocument(final DBBroker broker, final Txn transaction, final XmldbURI uri) throws TriggerException { + } + + @Override + public void afterCreateDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeUpdateDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + } + + @Override + public void afterUpdateDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeCopyDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI newUri) throws TriggerException { + } + + @Override + public void afterCopyDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI oldUri) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeMoveDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI newUri) throws TriggerException { + } + + @Override + public void afterMoveDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document, final XmldbURI oldUri) throws TriggerException { + processDocument(broker, document); + } + + @Override + public void beforeDeleteDocument(final DBBroker broker, final Txn transaction, final DocumentImpl document) throws TriggerException { + final String fileName = document.getFileURI().lastSegment().toString(); + if ("controller.json".equals(fileName) || fileName.endsWith("api.json")) { + final String collectionPath = document.getCollection().getURI().getCollectionPath(); + OpenApiServiceRegistry.getInstance().deregisterRoutes(collectionPath); + } + } + + @Override + public void afterDeleteDocument(final DBBroker broker, final Txn transaction, final XmldbURI uri) throws TriggerException { + } + + @Override + public void beforeUpdateDocumentMetadata(final DBBroker broker, final Txn txn, final DocumentImpl document) throws TriggerException { + } + + @Override + public void afterUpdateDocumentMetadata(final DBBroker broker, final Txn txn, final DocumentImpl document) throws TriggerException { + } + + /** + * Process a document that was just stored/updated. If it's a controller.json + * or api.json, parse and register routes. + */ + private void processDocument(final DBBroker broker, final DocumentImpl document) { + final String fileName = document.getFileURI().lastSegment().toString(); + if (!"controller.json".equals(fileName) && !fileName.endsWith("api.json")) { + return; + } + + if (!(document instanceof BinaryDocument)) { + LOG.debug("Ignoring non-binary document: {}", document.getURI()); + return; + } + + final String collectionPath = document.getCollection().getURI().getCollectionPath(); + + try { + if ("controller.json".equals(fileName)) { + processControllerJson(broker, (BinaryDocument) document, collectionPath); + } else { + // Direct api.json — register with default settings + processApiJson(broker, (BinaryDocument) document, collectionPath, null); + } + } catch (final Exception e) { + LOG.error("Failed to process OpenAPI document {}: {}", document.getURI(), e.getMessage(), e); + } + } + + /** + * Process a controller.json file. Reads the "apis" array and processes each spec. + */ + private void processControllerJson(final DBBroker broker, final BinaryDocument document, + final String collectionPath) throws IOException, org.exist.security.PermissionDeniedException { + final String json = readBinaryDocument(broker, document); + LOG.info("Processing controller.json for collection {}", collectionPath); + + // Simple JSON parsing for the "apis" array + // Extract spec paths from: {"apis": [{"spec": "modules/api.json", ...}]} + final List> apis = parseApisArray(json); + + if (apis.isEmpty()) { + LOG.warn("No 'apis' found in controller.json for {}", collectionPath); + return; + } + + final List allRoutes = new ArrayList<>(); + for (final Map api : apis) { + final String specPath = api.get("spec"); + if (specPath == null) { + continue; + } + final String prefix = api.get("prefix"); + + // Resolve spec path relative to collection + final XmldbURI specUri = XmldbURI.create(collectionPath).append(specPath); + final DocumentImpl specDoc = broker.getResource(specUri, org.exist.security.Permission.READ); + if (specDoc == null) { + LOG.warn("OpenAPI spec not found: {}", specUri); + continue; + } + + final String specJson = readBinaryDocument(broker, (BinaryDocument) specDoc); + final List routes = parseOpenApiSpec(specJson, prefix); + allRoutes.addAll(routes); + } + + if (!allRoutes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes(collectionPath, allRoutes); + } + } + + /** + * Process a standalone api.json file (no controller.json). + */ + private void processApiJson(final DBBroker broker, final BinaryDocument document, + final String collectionPath, final String prefix) throws IOException { + final String json = readBinaryDocument(broker, document); + LOG.info("Processing api.json for collection {}", collectionPath); + + final List routes = parseOpenApiSpec(json, prefix); + if (!routes.isEmpty()) { + OpenApiServiceRegistry.getInstance().registerRoutes(collectionPath, routes); + } + } + + /** + * Parse an OpenAPI 3.0 spec and extract routes. + * Uses Jackson streaming API for efficient parsing. + */ + static List parseOpenApiSpec(final String json, final String prefix) throws IOException { + final List routes = new ArrayList<>(); + final JsonFactory factory = new JsonFactory(); + + try (final JsonParser parser = factory.createParser(json)) { + // Navigate to "paths" object + if (!advanceTo(parser, "paths")) { + return routes; + } + + // parser is now at START_OBJECT for "paths" + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.FIELD_NAME) { + String pathPattern = parser.currentName(); + + // Apply prefix filter if specified + if (prefix != null && !pathPattern.startsWith(prefix)) { + parser.nextToken(); // skip value + parser.skipChildren(); + continue; + } + + parser.nextToken(); // START_OBJECT for this path + if (parser.currentToken() != JsonToken.START_OBJECT) { + continue; + } + + // Parse methods under this path + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.FIELD_NAME) { + final String method = parser.currentName().toUpperCase(); + if (!Set.of("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD").contains(method)) { + parser.nextToken(); + parser.skipChildren(); + continue; + } + + parser.nextToken(); // START_OBJECT for this operation + String operationId = null; + final Map spec = new HashMap<>(); + + // Find operationId in this operation object + int depth = 1; + while (depth > 0) { + final JsonToken token = parser.nextToken(); + if (token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) { + depth++; + } else if (token == JsonToken.END_OBJECT || token == JsonToken.END_ARRAY) { + depth--; + } else if (token == JsonToken.FIELD_NAME && depth == 1) { + if ("operationId".equals(parser.currentName())) { + parser.nextToken(); + operationId = parser.getValueAsString(); + } + } + } + + if (operationId != null) { + final Pattern regex = OpenApiServiceRegistry.compilePathPattern(pathPattern); + routes.add(new OpenApiServiceRegistry.Route( + method, pathPattern, regex, operationId, spec)); + LOG.debug("Parsed route: {} {} → {}", method, pathPattern, operationId); + } + } + } + } + } + } + + return routes; + } + + /** + * Parse the "apis" array from a controller.json string. + */ + static List> parseApisArray(final String json) throws IOException { + final List> apis = new ArrayList<>(); + final JsonFactory factory = new JsonFactory(); + + try (final JsonParser parser = factory.createParser(json)) { + if (!advanceTo(parser, "apis")) { + return apis; + } + + // parser is now at START_ARRAY for "apis" + while (parser.nextToken() != JsonToken.END_ARRAY) { + if (parser.currentToken() == JsonToken.START_OBJECT) { + final Map api = new HashMap<>(); + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.FIELD_NAME) { + final String key = parser.currentName(); + parser.nextToken(); + api.put(key, parser.getValueAsString()); + } + } + apis.add(api); + } + } + } + + return apis; + } + + /** + * Advance the parser to the value of a top-level field with the given name. + * Returns true if found, false if not. + */ + private static boolean advanceTo(final JsonParser parser, final String fieldName) throws IOException { + while (parser.nextToken() != null) { + if (parser.currentToken() == JsonToken.FIELD_NAME && fieldName.equals(parser.currentName())) { + parser.nextToken(); // advance to value + return true; + } + if (parser.currentToken() == JsonToken.START_OBJECT || parser.currentToken() == JsonToken.START_ARRAY) { + if (parser.currentToken() == JsonToken.START_OBJECT && parser.currentName() != null && !fieldName.equals(parser.currentName())) { + parser.skipChildren(); + } + } + } + return false; + } + + private String readBinaryDocument(final DBBroker broker, final BinaryDocument document) throws IOException { + try (final InputStream is = broker.getBinaryResource(document)) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java new file mode 100644 index 00000000000..bcd40f75857 --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java @@ -0,0 +1,432 @@ +/* + * 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.servlets; + +import com.fasterxml.jackson.core.JsonGenerator; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Part; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.repo.PackageService; +import org.exist.security.Subject; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.expath.pkg.repo.PackageException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Built-in REST API for package management. + *

+ * This servlet provides package CRUD operations (list, get, install, remove, + * update-check) as a built-in Java endpoint that does not depend on any XAR + * package. This ensures package management remains available even during + * package upgrades, solving the self-upgrade problem that affects XQuery-based + * package management implementations. + *

+ *

+ * Endpoints: + *

    + *
  • GET /api/packages — list all installed packages
  • + *
  • GET /api/packages/{name} — get single package details
  • + *
  • GET /api/packages/{name}/icon — get package icon
  • + *
  • POST /api/packages/install — install from registry (JSON) or upload (multipart)
  • + *
  • POST /api/packages/update-check — check for available updates
  • + *
  • DELETE /api/packages/{name} — remove package
  • + *
+ *

+ */ +public class PackageManagementServlet extends AbstractExistHttpServlet { + + private static final Logger LOG = LogManager.getLogger(PackageManagementServlet.class); + + private final PackageService packageService = new PackageService(); + + @Override + public Logger getLog() { + return LOG; + } + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + + try { + if (pathInfo == null || pathInfo.isEmpty() || pathInfo.equals("/")) { + // GET /api/packages — list all + handleListPackages(response); + } else if (pathInfo.endsWith("/icon")) { + // GET /api/packages/{name}/icon + final String name = pathInfo.substring(1, pathInfo.length() - "/icon".length()); + handleGetIcon(response, name); + } else { + // GET /api/packages/{name} + final String name = pathInfo.substring(1); + handleGetPackage(response, name); + } + } catch (final PackageException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + if (!user.hasDbaRole()) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "FORBIDDEN", "DBA role required for package installation"); + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + + try { + if ("/install".equals(pathInfo)) { + handleInstall(request, response, user); + } else if ("/update-check".equals(pathInfo)) { + handleUpdateCheck(request, response); + } else { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Unknown POST endpoint: " + pathInfo); + } + } catch (final PackageException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } catch (final EXistException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", e.getMessage()); + } + } + + @Override + protected void doDelete(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + if (!user.hasDbaRole()) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "FORBIDDEN", "DBA role required for package removal"); + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + if (pathInfo == null || pathInfo.length() <= 1) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Package name required"); + return; + } + + final String nameOrAbbrev = pathInfo.substring(1); + + try { + // Check for dependents first + final List dependents = packageService.findDependents(getPool(), nameOrAbbrev); + final boolean force = "true".equals(request.getParameter("force")); + if (!dependents.isEmpty() && !force) { + response.setStatus(HttpServletResponse.SC_CONFLICT); + response.setContentType("application/json"); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("code", "HAS_DEPENDENTS"); + gen.writeStringField("message", "Cannot remove: other packages depend on " + nameOrAbbrev); + gen.writeArrayFieldStart("dependents"); + for (final String dep : dependents) { + gen.writeString(dep); + } + gen.writeEndArray(); + gen.writeEndObject(); + } + return; + } + + try (final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.removePackage(broker, transaction, nameOrAbbrev); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } catch (final EXistException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", e.getMessage()); + } + } catch (final PackageException e) { + if (e.getMessage() != null && e.getMessage().contains("not found")) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "PACKAGE_NOT_FOUND", e.getMessage()); + } else { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } + } + } + + @Override + protected void doOptions(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setStatus(HttpServletResponse.SC_OK); + } + + // --- Handler methods --- + + private void handleListPackages(final HttpServletResponse response) + throws IOException, PackageException { + final List> packages = packageService.listPackages(getPool()); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartArray(); + for (final Map pkg : packages) { + writeMapAsJson(gen, pkg); + } + gen.writeEndArray(); + } + } + + private void handleGetPackage(final HttpServletResponse response, final String nameOrAbbrev) + throws IOException, PackageException { + final Map pkg = packageService.getPackage(getPool(), nameOrAbbrev); + if (pkg == null) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "PACKAGE_NOT_FOUND", "Package not found: " + nameOrAbbrev); + return; + } + writeJsonMap(response, HttpServletResponse.SC_OK, pkg); + } + + private void handleGetIcon(final HttpServletResponse response, final String nameOrAbbrev) + throws IOException, PackageException { + final Object[] icon = packageService.getPackageIcon(getPool(), nameOrAbbrev); + if (icon == null) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "ICON_NOT_FOUND", "No icon found for package: " + nameOrAbbrev); + return; + } + response.setContentType((String) icon[1]); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream()) { + os.write((byte[]) icon[0]); + } + } + + private void handleInstall(final HttpServletRequest request, final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + final String contentType = request.getContentType(); + + if (contentType != null && contentType.startsWith("multipart/")) { + // Upload a XAR file + handleInstallUpload(request, response, user); + } else { + // Install from registry (JSON body) + handleInstallFromRegistry(request, response, user); + } + } + + private void handleInstallFromRegistry(final HttpServletRequest request, + final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + // Parse JSON body manually (avoid adding a JSON parsing dependency) + final String body; + try (final InputStream is = request.getInputStream()) { + body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + final String name = PackageService.extractJsonStringValue(body, "name"); + final String url = PackageService.extractJsonStringValue(body, "url"); + final String version = PackageService.extractJsonStringValue(body, "version"); + + if (name == null || name.isEmpty() || url == null || url.isEmpty()) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Missing required fields: name, url"); + return; + } + + try (final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.installFromRegistry( + broker, transaction, name, url, version); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } + } + + private void handleInstallUpload(final HttpServletRequest request, + final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + try { + final Part xarPart = request.getPart("xar"); + if (xarPart == null) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Missing 'xar' file part in multipart upload"); + return; + } + try (final InputStream is = xarPart.getInputStream(); + final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.installFromUpload( + broker, transaction, is); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } + } catch (final ServletException e) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Failed to process multipart upload: " + e.getMessage()); + } + } + + private void handleUpdateCheck(final HttpServletRequest request, + final HttpServletResponse response) + throws IOException, PackageException { + // Parse optional JSON body for registry URL + String registryUrl = "https://exist-db.org/exist/apps/public-repo"; + final String contentType = request.getContentType(); + if (contentType != null && contentType.contains("json")) { + final String body; + try (final InputStream is = request.getInputStream()) { + body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + final String url = PackageService.extractJsonStringValue(body, "registry"); + if (url != null && !url.isEmpty()) { + registryUrl = url; + } + } + + final List> updates = packageService.checkUpdates(getPool(), registryUrl); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("registry", registryUrl); + gen.writeArrayFieldStart("updates"); + for (final Map update : updates) { + writeMapAsJson(gen, update); + } + gen.writeEndArray(); + gen.writeEndObject(); + } + } + + // --- JSON helpers --- + + private JsonGenerator createJsonGenerator(final OutputStream os) throws IOException { + final com.fasterxml.jackson.core.JsonFactory factory = new com.fasterxml.jackson.core.JsonFactory(); + final JsonGenerator gen = factory.createGenerator(os); + gen.useDefaultPrettyPrinter(); + return gen; + } + + private void writeError(final HttpServletResponse response, final int statusCode, + final String code, final String message) throws IOException { + response.setContentType("application/json"); + response.setStatus(statusCode); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("code", code); + gen.writeStringField("message", message); + gen.writeEndObject(); + } + } + + private void writeJsonMap(final HttpServletResponse response, final int statusCode, + final Map data) throws IOException { + response.setContentType("application/json"); + response.setStatus(statusCode); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + writeMapAsJson(gen, data); + } + } + + @SuppressWarnings("unchecked") + private void writeMapAsJson(final JsonGenerator gen, final Map map) throws IOException { + gen.writeStartObject(); + for (final Map.Entry entry : map.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (value == null) { + gen.writeNullField(key); + } else if (value instanceof String s) { + gen.writeStringField(key, s); + } else if (value instanceof Boolean b) { + gen.writeBooleanField(key, b); + } else if (value instanceof Number n) { + gen.writeNumberField(key, n.longValue()); + } else if (value instanceof List list) { + gen.writeArrayFieldStart(key); + for (final Object item : list) { + if (item instanceof Map itemMap) { + writeMapAsJson(gen, (Map) itemMap); + } else if (item instanceof String s) { + gen.writeString(s); + } else { + gen.writeString(String.valueOf(item)); + } + } + gen.writeEndArray(); + } else if (value instanceof Map nested) { + gen.writeFieldName(key); + writeMapAsJson(gen, (Map) nested); + } else { + gen.writeStringField(key, String.valueOf(value)); + } + } + gen.writeEndObject(); + } + + private String normalizePath(final String pathInfo) { + if (pathInfo == null) { + return null; + } + // URL-decode the path + return java.net.URLDecoder.decode(pathInfo, java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/exist-services/src/main/java/org/exist/repo/PackageService.java b/exist-services/src/main/java/org/exist/repo/PackageService.java new file mode 100644 index 00000000000..94be8e20ac8 --- /dev/null +++ b/exist-services/src/main/java/org/exist/repo/PackageService.java @@ -0,0 +1,470 @@ +/* + * 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.repo; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.exist.util.io.TemporaryFileManager; +import org.expath.pkg.repo.*; +import org.expath.pkg.repo.Package; +import org.expath.pkg.repo.tui.BatchUserInteraction; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.annotation.Nullable; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.*; + +/** + * Business logic for package management operations. + * Used by {@link org.exist.http.servlets.PackageManagementServlet} to provide + * a built-in REST API for package CRUD that does not depend on any XAR package. + */ +public class PackageService { + + private static final Logger LOG = LogManager.getLogger(PackageService.class); + + private static final String PKG_NAMESPACE = "http://expath.org/ns/pkg"; + private static final String REPO_NAMESPACE = "http://exist-db.org/xquery/repo"; + + /** + * List all installed packages with metadata. + */ + public List> listPackages(final BrokerPool pool) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final List> result = new ArrayList<>(); + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + result.add(buildPackageInfo(pkg)); + } + return result; + } + + /** + * Get metadata for a single package by name (URI) or abbreviation. + */ + @Nullable + public Map getPackage(final BrokerPool pool, final String nameOrAbbrev) throws PackageException { + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + return null; + } + return buildPackageInfo(pkg); + } + + /** + * Get a package icon file. + * Returns a two-element array: [byte[], contentType] or null if no icon found. + */ + @Nullable + public Object[] getPackageIcon(final BrokerPool pool, final String nameOrAbbrev) throws PackageException, IOException { + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + return null; + } + final Path pkgDir = getPackageDir(pkg); + // Try common icon filenames + for (final String iconName : new String[]{"icon.png", "icon.svg", "icon.jpg", "icon.gif"}) { + final Path iconFile = pkgDir.resolve(iconName); + if (Files.isReadable(iconFile)) { + final String contentType; + if (iconName.endsWith(".svg")) { + contentType = "image/svg+xml"; + } else if (iconName.endsWith(".png")) { + contentType = "image/png"; + } else if (iconName.endsWith(".jpg")) { + contentType = "image/jpeg"; + } else { + contentType = "image/gif"; + } + return new Object[]{Files.readAllBytes(iconFile), contentType}; + } + } + return null; + } + + /** + * Install a package from a remote registry. + */ + public Map installFromRegistry(final DBBroker broker, final Txn transaction, + final String name, final String registryUrl, + @Nullable final String version) throws PackageException, IOException { + final RepoPackageLoader loader = new RepoPackageLoader(registryUrl); + // Build version constraint + PackageLoader.Version pkgVersion = null; + if (version != null && !version.isEmpty()) { + pkgVersion = new PackageLoader.Version(version, true); + } + + // Download the XAR from the registry + final XarSource xar = loader.load(name, pkgVersion); + if (xar == null) { + throw new PackageException("Package not found in registry: " + name); + } + + // Install and deploy + final Deployment deployment = new Deployment(); + final Optional target = deployment.installAndDeploy(broker, transaction, xar, loader); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", name); + target.ifPresent(t -> result.put("target", t)); + return result; + } + + /** + * Install a package from an uploaded XAR file. + */ + public Map installFromUpload(final DBBroker broker, final Txn transaction, + final InputStream xarStream) throws PackageException, IOException { + // Save to temporary file + final TemporaryFileManager tempManager = TemporaryFileManager.getInstance(); + final Path tempFile = tempManager.getTemporaryFile(); + Files.copy(xarStream, tempFile, StandardCopyOption.REPLACE_EXISTING); + + final XarFileSource xarSource = new XarFileSource(tempFile); + final Deployment deployment = new Deployment(); + final Optional target = deployment.installAndDeploy(broker, transaction, xarSource, null); + + // Read package name from the XAR descriptor + final Optional descriptor = deployment.getDescriptor(broker, xarSource); + final String name = descriptor.map(d -> d.getDocumentElement().getAttribute("name")).orElse("unknown"); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", name); + target.ifPresent(t -> result.put("target", t)); + return result; + } + + /** + * Remove (undeploy + delete) a package. + */ + public Map removePackage(final DBBroker broker, final Txn transaction, + final String nameOrAbbrev) throws PackageException { + final BrokerPool pool = broker.getBrokerPool(); + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + throw new PackageException("Package not found: " + nameOrAbbrev); + } + final String pkgName = pkg.getName(); + + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + throw new PackageException("EXPath repository not available"); + } + + // Undeploy (remove database resources) + final Deployment deployment = new Deployment(); + deployment.undeploy(broker, transaction, pkgName, maybeRepo); + + // Remove from repository + final Repository parentRepo = maybeRepo.get().getParentRepo(); + parentRepo.removePackage(pkgName, false, new BatchUserInteraction()); + maybeRepo.get().reportAction(ExistRepository.Action.UNINSTALL, pkgName); + + // Clear XQuery cache + pool.getXQueryPool().clear(); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", pkgName); + return result; + } + + /** + * Find packages that depend on the given package. + */ + public List findDependents(final BrokerPool pool, final String packageName) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final List dependents = new ArrayList<>(); + + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + if (pkg.getName().equals(packageName)) { + continue; + } + // Read expath-pkg.xml to check dependencies + final Path pkgDir = getPackageDir(pkg); + final Path expathFile = pkgDir.resolve("expath-pkg.xml"); + if (Files.isReadable(expathFile)) { + try { + final Document doc = parseXml(expathFile); + final NodeList deps = doc.getElementsByTagNameNS(PKG_NAMESPACE, "dependency"); + for (int i = 0; i < deps.getLength(); i++) { + final Element dep = (Element) deps.item(i); + if (packageName.equals(dep.getAttribute("package"))) { + dependents.add(pkg.getName()); + break; + } + } + } catch (final Exception e) { + LOG.warn("Failed to parse expath-pkg.xml for package {}", pkg.getName(), e); + } + } + } + return dependents; + } + + /** + * Check a remote registry for available updates. + */ + public List> checkUpdates(final BrokerPool pool, final String registryUrl) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final String findUrl = registryUrl + "/find"; + final String processorVersion = org.exist.SystemProperties.getInstance() + .getSystemProperty("product-version", "7.0.0"); + + final List> updates = new ArrayList<>(); + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + final String abbrev = pkg.getAbbrev(); + final String installed = pkg.getVersion(); + if (abbrev == null || installed == null) { + continue; + } + try { + final String queryUrl = findUrl + + "?abbrev=" + URLEncoder.encode(abbrev, StandardCharsets.UTF_8) + + "&processor=http://exist-db.org" + + "&info=true" + + "&processorVersion=" + URLEncoder.encode(processorVersion, StandardCharsets.UTF_8); + + final HttpURLConnection conn = (HttpURLConnection) URI.create(queryUrl).toURL().openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Accept", "application/json"); + conn.connect(); + + if (conn.getResponseCode() == 200) { + // Parse the simple JSON response to extract version + // The response format is: {"version": "x.y.z", ...} + final String body; + try (final InputStream is = conn.getInputStream()) { + body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + final String available = extractJsonStringValue(body, "version"); + if (available != null && !available.equals(installed)) { + final Map update = new LinkedHashMap<>(); + update.put("name", pkg.getName()); + update.put("abbrev", abbrev); + update.put("installed", installed); + update.put("available", available); + updates.add(update); + } + } + } catch (final IOException e) { + LOG.debug("Failed to check updates for package {}: {}", abbrev, e.getMessage()); + } + } + return updates; + } + + // --- Private helpers --- + + @Nullable + private Package resolvePackage(final BrokerPool pool, final String nameOrAbbrev) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return null; + } + final Repository repo = maybeRepo.get().getParentRepo(); + + // Try as package name (URI) first + final Packages byName = repo.getPackages(nameOrAbbrev); + if (byName != null) { + return byName.latest(); + } + + // Try as abbreviation + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + if (nameOrAbbrev.equals(pkg.getAbbrev())) { + return pkg; + } + } + return null; + } + + private Path getPackageDir(final Package pkg) { + final FileSystemStorage.FileSystemResolver resolver = + (FileSystemStorage.FileSystemResolver) pkg.getResolver(); + return resolver.resolveResourceAsFile(""); + } + + private Map buildPackageInfo(final Package pkg) { + final Map info = new LinkedHashMap<>(); + info.put("name", pkg.getName()); + info.put("abbrev", pkg.getAbbrev()); + info.put("version", pkg.getVersion()); + + // Read additional metadata from descriptors on the filesystem + final Path pkgDir = getPackageDir(pkg); + readExpathMetadata(pkgDir, info); + readRepoMetadata(pkgDir, info); + return info; + } + + private void readExpathMetadata(final Path pkgDir, final Map info) { + final Path expathFile = pkgDir.resolve("expath-pkg.xml"); + if (!Files.isReadable(expathFile)) { + return; + } + try { + final Document doc = parseXml(expathFile); + final Element root = doc.getDocumentElement(); + // Title + final NodeList titles = root.getElementsByTagNameNS(PKG_NAMESPACE, "title"); + if (titles.getLength() > 0) { + info.put("title", titles.item(0).getTextContent()); + } + // Dependencies + final NodeList deps = root.getElementsByTagNameNS(PKG_NAMESPACE, "dependency"); + final List> depList = new ArrayList<>(); + for (int i = 0; i < deps.getLength(); i++) { + final Element dep = (Element) deps.item(i); + final Map depInfo = new LinkedHashMap<>(); + if (!dep.getAttribute("package").isEmpty()) { + depInfo.put("package", dep.getAttribute("package")); + } + if (!dep.getAttribute("processor").isEmpty()) { + depInfo.put("processor", dep.getAttribute("processor")); + } + if (!dep.getAttribute("semver-min").isEmpty()) { + depInfo.put("semverMin", dep.getAttribute("semver-min")); + } + if (!dep.getAttribute("semver-max").isEmpty()) { + depInfo.put("semverMax", dep.getAttribute("semver-max")); + } + if (!dep.getAttribute("semver").isEmpty()) { + depInfo.put("semver", dep.getAttribute("semver")); + } + if (!dep.getAttribute("version").isEmpty()) { + depInfo.put("version", dep.getAttribute("version")); + } + depList.add(depInfo); + } + info.put("dependencies", depList); + } catch (final Exception e) { + LOG.debug("Failed to read expath-pkg.xml from {}", pkgDir, e); + } + } + + private void readRepoMetadata(final Path pkgDir, final Map info) { + final Path repoFile = pkgDir.resolve("repo.xml"); + if (!Files.isReadable(repoFile)) { + return; + } + try { + final Document doc = parseXml(repoFile); + final Element root = doc.getDocumentElement(); + setTextElement(root, REPO_NAMESPACE, "description", "description", info); + setTextElement(root, REPO_NAMESPACE, "website", "website", info); + setTextElement(root, REPO_NAMESPACE, "license", "license", info); + setTextElement(root, REPO_NAMESPACE, "type", "type", info); + setTextElement(root, REPO_NAMESPACE, "target", "target", info); + setTextElement(root, REPO_NAMESPACE, "deployed", "deployed", info); + // Authors + final NodeList authors = root.getElementsByTagNameNS(REPO_NAMESPACE, "author"); + if (authors.getLength() > 0) { + final List authorList = new ArrayList<>(); + for (int i = 0; i < authors.getLength(); i++) { + authorList.add(authors.item(i).getTextContent().trim()); + } + info.put("authors", authorList); + } + } catch (final Exception e) { + LOG.debug("Failed to read repo.xml from {}", pkgDir, e); + } + } + + private void setTextElement(final Element root, final String ns, final String localName, + final String key, final Map info) { + final NodeList nodes = root.getElementsByTagNameNS(ns, localName); + if (nodes.getLength() > 0) { + final String value = nodes.item(0).getTextContent().trim(); + if (!value.isEmpty()) { + info.put(key, value); + } + } + } + + private Document parseXml(final Path file) throws Exception { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + final DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(file.toFile()); + } + + /** + * Simple extraction of a string value from a JSON object. + * Avoids pulling in a JSON parsing library for this single use case. + */ + @Nullable + public static String extractJsonStringValue(final String json, final String key) { + final String search = "\"" + key + "\""; + final int keyIdx = json.indexOf(search); + if (keyIdx < 0) { + return null; + } + final int colonIdx = json.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) { + return null; + } + final int startQuote = json.indexOf('"', colonIdx + 1); + if (startQuote < 0) { + return null; + } + final int endQuote = json.indexOf('"', startQuote + 1); + if (endQuote < 0) { + return null; + } + return json.substring(startQuote + 1, endQuote); + } +} diff --git a/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java b/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java new file mode 100644 index 00000000000..0ce8dd4641e --- /dev/null +++ b/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java @@ -0,0 +1,88 @@ +/* + * 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.repo; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.SystemProperties; +import org.exist.util.io.TemporaryFileManager; +import org.expath.pkg.repo.XarFileSource; +import org.expath.pkg.repo.XarSource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Loads packages from a remote public repository (e.g. https://exist-db.org/exist/apps/public-repo). + * Used by {@link Deployment} to resolve package dependencies during installation, + * and by {@link PackageService} for direct package installation from a registry. + */ +public record RepoPackageLoader(String repoURL) implements PackageLoader { + + private static final Logger LOG = LogManager.getLogger(RepoPackageLoader.class); + + private static final int CONNECT_TIMEOUT = 15_000; + private static final int READ_TIMEOUT = 15_000; + + @Override + public XarSource load(final String name, final Version version) throws IOException { + String pkgURL = repoURL + "?name=" + URLEncoder.encode(name, StandardCharsets.UTF_8) + + "&processor=" + SystemProperties.getInstance().getSystemProperty("product-version", "2.2.0"); + if (version != null) { + if (version.getMin() != null) { + pkgURL += "&semver-min=" + version.getMin(); + } + if (version.getMax() != null) { + pkgURL += "&semver-max=" + version.getMax(); + } + if (version.getSemVer() != null) { + pkgURL += "&semver=" + version.getSemVer(); + } + if (version.getVersion() != null) { + pkgURL += "&version=" + URLEncoder.encode(version.getVersion(), StandardCharsets.UTF_8); + } + } + LOG.info("Retrieving package from {}", pkgURL); + final HttpURLConnection connection = (HttpURLConnection) URI.create(pkgURL).toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", "eXist-db Package Manager"); + connection.connect(); + + try (final InputStream is = connection.getInputStream()) { + final TemporaryFileManager temporaryFileManager = TemporaryFileManager.getInstance(); + final Path outFile = temporaryFileManager.getTemporaryFile(); + Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING); + return new XarFileSource(outFile); + } catch (final IOException e) { + throw new IOException("Failed to download package from " + pkgURL + ": " + e.getMessage(), e); + } + } +} diff --git a/pom.xml b/pom.xml index f82e32d6b2b..09b6c322159 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ exist-jetty-config exist-samples exist-service + exist-services exist-start extensions exist-xqts