From 6ad2f8518fefb29f98719f3d4aeefacc24e39447 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Fri, 13 Mar 2026 09:10:43 +0300 Subject: [PATCH 1/2] SwaggerRouter: validates requests and responses against OpenAPI specs --- openig-core/pom.xml | 9 +- .../openig/alias/CoreClassAliasResolver.java | 2 + .../router/AbstractDirectoryMonitor.java | 136 ++++++++ .../handler/router/DirectoryMonitor.java | 117 +------ .../handler/router/FileChangeListener.java | 3 +- .../openig/handler/router/FileChangeSet.java | 3 +- .../router/SwaggerDirectoryMonitor.java | 38 ++ .../openig/handler/router/SwaggerRouter.java | 327 ++++++++++++++++++ .../handler/router/SwaggerRouterTest.java | 292 ++++++++++++++++ .../handler/router/swagger/petstore.yaml | 120 +++++++ .../asciidoc/reference/handlers-conf.adoc | 137 +++++++- 11 files changed, 1078 insertions(+), 106 deletions(-) create mode 100644 openig-core/src/main/java/org/forgerock/openig/handler/router/AbstractDirectoryMonitor.java create mode 100644 openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerDirectoryMonitor.java create mode 100644 openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerRouter.java create mode 100644 openig-core/src/test/java/org/openidentityplatform/openig/handler/router/SwaggerRouterTest.java create mode 100644 openig-core/src/test/resources/org/openidentityplatform/openig/handler/router/swagger/petstore.yaml diff --git a/openig-core/pom.xml b/openig-core/pom.xml index bc8667b78..6f09ddbd6 100644 --- a/openig-core/pom.xml +++ b/openig-core/pom.xml @@ -14,7 +14,7 @@ Copyright 2010-2011 ApexIdentity Inc. Portions Copyright 2011-2016 ForgeRock AS. - Portions copyright 2025 3A Systems LLC. + Portions copyright 2026 3A Systems LLC. --> 4.0.0 @@ -161,6 +161,13 @@ commons-io commons-io + + + com.atlassian.oai + swagger-request-validator-core + 2.46.0 + + org.glassfish.grizzly grizzly-http-server diff --git a/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java b/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java index 2ad37dc94..a8b5af641 100644 --- a/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java +++ b/openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java @@ -63,6 +63,7 @@ import org.forgerock.openig.thread.ScheduledExecutorServiceHeaplet; import org.openidentityplatform.openig.filter.ICAPFilter; import org.openidentityplatform.openig.filter.JwtBuilderFilter; +import org.openidentityplatform.openig.handler.router.SwaggerRouter; import org.openidentityplatform.openig.mq.EmbeddedKafka; import org.openidentityplatform.openig.mq.MQ_IBM; import org.openidentityplatform.openig.mq.MQ_Kafka; @@ -112,6 +113,7 @@ public class CoreClassAliasResolver implements ClassAliasResolver { ALIASES.put("SqlAttributesFilter", SqlAttributesFilter.class); ALIASES.put("StaticRequestFilter", StaticRequestFilter.class); ALIASES.put("StaticResponseHandler", StaticResponseHandler.class); + ALIASES.put("SwaggerRouter", SwaggerRouter.class); ALIASES.put("SwitchFilter", SwitchFilter.class); ALIASES.put("TemporaryStorage", TemporaryStorageHeaplet.class); ALIASES.put("ThrottlingFilter", ThrottlingFilterHeaplet.class); diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/AbstractDirectoryMonitor.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/AbstractDirectoryMonitor.java new file mode 100644 index 000000000..953c8ce59 --- /dev/null +++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/AbstractDirectoryMonitor.java @@ -0,0 +1,136 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.forgerock.openig.handler.router; + +import org.forgerock.util.annotations.VisibleForTesting; + +import java.io.File; +import java.io.FileFilter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import static java.util.Arrays.asList; + +public abstract class AbstractDirectoryMonitor { + /** + * Monitored directory. + */ + protected final File directory; + + /** + * Snapshot of the directory content. It maps the {@link File} to its {@linkplain File#lastModified() last modified} + * value. It represents the currently "managed" files. + */ + protected final Map snapshot; + + protected final Lock lock = new ReentrantLock(); + + /** + * Builds a new monitor watching for changes in the given {@literal directory} that will notify the given listener. + * This constructor is intended for test cases where it's useful to provide an initial state under control. + * @param directory + * a non-{@literal null} directory (it may or may not exist) to monitor + * @param snapshot + * initial state of the snapshot + */ + public AbstractDirectoryMonitor(final File directory, final Map snapshot) { + this.directory = directory; + this.snapshot = snapshot; + } + + /** + * Monitor the directory and notify the listener. + * @param listener the listener to notify about the changes + */ + public void monitor(FileChangeListener listener) { + if (lock.tryLock()) { + try { + FileChangeSet fileChangeSet = createFileChangeSet(); + if (fileChangeSet.isEmpty()) { + // If there is no change to propagate, simply return + return; + } + // Invoke listeners + listener.onChanges(fileChangeSet); + } finally { + lock.unlock(); + } + } + } + + /** + * Returns a snapshot of the changes compared to the previous scan. + * @return a snapshot of the changes compared to the previous scan. + */ + @VisibleForTesting + FileChangeSet createFileChangeSet() { + // Take a snapshot of the current directory + List latest = Collections.emptyList(); + if (directory.isDirectory()) { + latest = new ArrayList<>(asList(directory.listFiles(getFileFilter()))); + } + + // Detect added files + // (in latest but not in known) + Set added = new HashSet<>(); + for (File candidate : new ArrayList<>(latest)) { + if (!snapshot.containsKey(candidate)) { + added.add(candidate); + latest.remove(candidate); + } + } + + // Detect removed files + // (in known but not in latest) + Set removed = new HashSet<>(); + for (File candidate : new ArrayList<>(snapshot.keySet())) { + if (!latest.contains(candidate)) { + removed.add(candidate); + snapshot.remove(candidate); + } + } + + // Detect modified files + // Now, latest and known list should have the same Files inside + Set modified = new HashSet<>(); + for (File candidate : latest) { + long lastModified = snapshot.get(candidate); + if (lastModified < candidate.lastModified()) { + // File has changed since last check + modified.add(candidate); + snapshot.put(candidate, candidate.lastModified()); + } + } + + // Append the added files to the known list for next processing step + for (File file : added) { + // Store their last modified value + snapshot.put(file, file.lastModified()); + } + + return new FileChangeSet(directory, added, modified, removed); + } + + protected abstract FileFilter getFileFilter(); + +} diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/DirectoryMonitor.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/DirectoryMonitor.java index 27e78dfc7..88478722d 100644 --- a/openig-core/src/main/java/org/forgerock/openig/handler/router/DirectoryMonitor.java +++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/DirectoryMonitor.java @@ -12,12 +12,15 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems LLC */ package org.forgerock.openig.handler.router; -import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; -import static java.util.Arrays.asList; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.forgerock.http.util.Json; +import org.forgerock.json.JsonValue; +import org.forgerock.util.annotations.VisibleForTesting; import java.io.File; import java.io.FileFilter; @@ -25,21 +28,10 @@ import java.io.IOException; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import org.forgerock.http.util.Json; -import org.forgerock.json.JsonValue; -import org.forgerock.util.annotations.VisibleForTesting; - -import com.fasterxml.jackson.databind.ObjectMapper; +import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; /** * A {@link DirectoryMonitor} monitors a given directory. It watches the direct content (changes inside @@ -56,7 +48,7 @@ * @see FileChangeListener * @since 2.2 */ -class DirectoryMonitor { +public class DirectoryMonitor extends AbstractDirectoryMonitor { private static final ObjectMapper MAPPER; static { @@ -64,19 +56,6 @@ class DirectoryMonitor { MAPPER.configure(INDENT_OUTPUT, true); } - /** - * Monitored directory. - */ - private final File directory; - - /** - * Snapshot of the directory content. It maps the {@link File} to its {@linkplain File#lastModified() last modified} - * value. It represents the currently "managed" files. - */ - private final Map snapshot; - - private Lock lock = new ReentrantLock(); - /** * Builds a new monitor watching for changes in the given {@literal directory} that will notify the given listener. * It starts with an empty snapshot (at first run, all discovered files will be considered as new). @@ -85,7 +64,7 @@ class DirectoryMonitor { * a non-{@literal null} directory (it may or may not exists) to monitor */ public DirectoryMonitor(final File directory) { - this(directory, new HashMap()); + super(directory, new HashMap<>()); } /** @@ -97,29 +76,10 @@ public DirectoryMonitor(final File directory) { * initial state of the snapshot */ public DirectoryMonitor(final File directory, final Map snapshot) { - this.directory = directory; - this.snapshot = snapshot; + super(directory, snapshot); } - /** - * Monitor the directory and notify the listener. - * @param listener the listener to notify about the changes - */ - public void monitor(FileChangeListener listener) { - if (lock.tryLock()) { - try { - FileChangeSet fileChangeSet = createFileChangeSet(); - if (fileChangeSet.isEmpty()) { - // If there is no change to propagate, simply return - return; - } - // Invoke listeners - listener.onChanges(fileChangeSet); - } finally { - lock.unlock(); - } - } - } + /** * Returns a snapshot of the changes compared to the previous scan. @@ -127,65 +87,18 @@ public void monitor(FileChangeListener listener) { */ @VisibleForTesting FileChangeSet createFileChangeSet() { - // Take a snapshot of the current directory - List latest = Collections.emptyList(); - if (directory.isDirectory()) { - latest = new ArrayList<>(asList(directory.listFiles(jsonFiles()))); - } - - // Detect added files - // (in latest but not in known) - Set added = new HashSet<>(); - for (File candidate : new ArrayList<>(latest)) { - if (!snapshot.containsKey(candidate)) { - added.add(candidate); - latest.remove(candidate); - } - } - - // Detect removed files - // (in known but not in latest) - Set removed = new HashSet<>(); - for (File candidate : new ArrayList<>(snapshot.keySet())) { - if (!latest.contains(candidate)) { - removed.add(candidate); - snapshot.remove(candidate); - } - } - - // Detect modified files - // Now, latest and known list should have the same Files inside - Set modified = new HashSet<>(); - for (File candidate : latest) { - long lastModified = snapshot.get(candidate); - if (lastModified < candidate.lastModified()) { - // File has changed since last check - modified.add(candidate); - snapshot.put(candidate, candidate.lastModified()); - } - } - - // Append the added files to the known list for next processing step - for (File file : added) { - // Store their last modified value - snapshot.put(file, file.lastModified()); - } - - return new FileChangeSet(directory, added, modified, removed); + return super.createFileChangeSet(); } + /** * Factory method to be used as a fluent {@link FileFilter} declaration. * * @return a filter for {@literal .json} files */ - private static FileFilter jsonFiles() { - return new FileFilter() { - @Override - public boolean accept(final File path) { - return path.isFile() && path.getName().endsWith(".json"); - } - }; + @Override + protected FileFilter getFileFilter() { + return path -> path.isFile() && path.getName().endsWith(".json"); } void store(String routeId, JsonValue routeConfig) throws IOException { diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeListener.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeListener.java index 54cc4dd01..b15e4bf87 100644 --- a/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeListener.java +++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeListener.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2014 ForgeRock AS. + * Portions copyright 2026 3A Systems LLC. */ package org.forgerock.openig.handler.router; @@ -21,7 +22,7 @@ * * @since 2.2 */ -interface FileChangeListener { +public interface FileChangeListener { /** * Notify that changes has been detected in the monitored directory. diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeSet.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeSet.java index b4b422c9b..c6fb503e6 100644 --- a/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeSet.java +++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/FileChangeSet.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. + * Portions copyright 2026 3A Systems LLC. */ package org.forgerock.openig.handler.router; @@ -26,7 +27,7 @@ * * @since 2.2 */ -class FileChangeSet { +public class FileChangeSet { /** * Scanned directory. diff --git a/openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerDirectoryMonitor.java b/openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerDirectoryMonitor.java new file mode 100644 index 000000000..1b1b752a0 --- /dev/null +++ b/openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerDirectoryMonitor.java @@ -0,0 +1,38 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.handler.router; + +import org.forgerock.openig.handler.router.AbstractDirectoryMonitor; + +import java.io.File; +import java.io.FileFilter; +import java.util.HashMap; + +public class SwaggerDirectoryMonitor extends AbstractDirectoryMonitor { + + public SwaggerDirectoryMonitor(File routes) { + super(routes, new HashMap<>()); + } + + @Override + protected FileFilter getFileFilter() { + return path -> path.isFile() && + (path.getName().endsWith(".json") + || path.getName().endsWith(".yaml") + || path.getName().endsWith("*.yml")); + } +} diff --git a/openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerRouter.java b/openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerRouter.java new file mode 100644 index 000000000..7aabb00fe --- /dev/null +++ b/openig-core/src/main/java/org/openidentityplatform/openig/handler/router/SwaggerRouter.java @@ -0,0 +1,327 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.handler.router; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.model.SimpleResponse; +import com.atlassian.oai.validator.report.ValidationReport; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; +import org.forgerock.http.Handler; +import org.forgerock.http.protocol.Request; +import org.forgerock.http.protocol.Response; +import org.forgerock.http.protocol.Responses; +import org.forgerock.http.protocol.Status; +import org.forgerock.openig.config.Environment; +import org.forgerock.openig.handler.router.FileChangeListener; +import org.forgerock.openig.handler.router.FileChangeSet; +import org.forgerock.openig.heap.GenericHeaplet; +import org.forgerock.openig.heap.HeapException; +import org.forgerock.services.context.Context; +import org.forgerock.util.promise.NeverThrowsException; +import org.forgerock.util.promise.Promise; +import org.forgerock.util.promise.Promises; +import org.forgerock.util.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.forgerock.json.JsonValueFunctions.duration; +import static org.forgerock.json.JsonValueFunctions.file; +import static org.forgerock.openig.heap.Keys.ENVIRONMENT_HEAP_KEY; +import static org.forgerock.openig.heap.Keys.SCHEDULED_EXECUTOR_SERVICE_HEAP_KEY; +import static org.forgerock.openig.util.JsonValues.requiredHeapObject; + +/** + * An {@link Handler} that validates incoming requests and outgoing responses against one + * or more OpenAPI (Swagger) specifications loaded from a monitored directory. + * + *

On each request, every loaded validator is tried in turn. A request is dispatched to the + * {@link #upstreamHandler} only when it matches a specification without errors. If the request + * matches a specification but violates its constraints, a {@code 400 Bad Request} response is + * returned immediately. If no specification recognises the request path or method, a + * {@code 404 Not Found} response is returned. + * + *

Upstream responses are also validated against the matched specification. A response that + * does not conform to the specification is replaced with a {@code 502 Bad Gateway} response. + * + *

Specification files are watched via a {@link SwaggerDirectoryMonitor}; files can be added, + * removed, or modified at runtime without restarting the gateway. The associated + * {@link Heaplet} wires the monitor to a {@link ScheduledExecutorService} so that the directory + * is re-scanned at a configurable interval (default: 10 seconds). + *

+ *    {@code
+ *    {
+ *      "name": "SwaggerRouter",
+ *      "type": "SwaggerRouter",
+ *      "config": {
+ *        "directory": "/config/swagger",
+ *        "handler": "ClientHandler",
+ *        "scanInterval": "2 seconds"
+ *      }
+ *    }
+ * }
+ * 
+ * + *

Heap configuration properties (used by {@link Heaplet}): + *

    + *
  • {@code directory} – path to the directory containing OpenAPI specification files. + * Defaults to {@code config/swagger}.
  • + *
  • {@code scanInterval} – how often the directory is rescanned for changes, expressed as + * a {@link Duration} string (e.g. {@code "30 seconds"}). Defaults to {@code "10 seconds"}. + * Set to {@code "0"} to disable periodic rescanning.
  • + *
  • {@code defaultHandler} – the downstream {@link Handler} to which matched requests are + * forwarded. Optional; if absent, matched requests will result in a NullPointerException + * at runtime.
  • + *
+ */ +public class SwaggerRouter implements FileChangeListener, Handler { + + private static final Logger logger = LoggerFactory.getLogger(SwaggerRouter.class); + + + /** + * The upstream handler which should be invoked when no routes match the + * request. + */ + private final Handler upstreamHandler; + + /** + * Map of loaded OpenAPI validators keyed by the specification file name. + * Access is thread-safe via {@link ConcurrentHashMap}; structural mutations + * (add/remove) are performed on the {@link FileChangeListener} callbacks. + */ + Map validators; + + /** + * Error codes returned by the OpenAPI validator that indicate the request path or + * HTTP method is not defined in the specification, as opposed to a constraint violation. + * When only one of these codes is present the request is silently skipped (not rejected) + * so that the next loaded specification can be tried. + */ + final Set MISSING_OPERATION_ERROR_CODES = Set.of("validation.request.path.missing", "validation.request.operation.notAllowed"); + + /** + * Builds a swagger router that loads its configuration from the given directory. + * @param handler the upstream handler + */ + public SwaggerRouter(Handler handler) { + this.upstreamHandler = handler; + validators = new ConcurrentHashMap<>(); + } + + + @Override + public Promise handle(Context context, Request request) { + + for(Map.Entry validatorEntry : validators.entrySet()) { + OpenApiInteractionValidator validator = validatorEntry.getValue(); + final com.atlassian.oai.validator.model.Request validatorRequest; + try { + validatorRequest = validatorRequestOf(request); + } catch (IOException e) { + logger.error("exception while reading the request"); + return Promises.newResultPromise(new Response(Status.INTERNAL_SERVER_ERROR)); + } + + ValidationReport requestValidationReport = validator.validateRequest(validatorRequest); + if(!requestValidationReport.hasErrors()) { + return upstreamHandler.handle(context, request).then(response -> { + final com.atlassian.oai.validator.model.Response validatorResponse; + try { + validatorResponse = validatorResponseOf(response); + } catch (IOException e) { + logger.error("exception while reading the response"); + return new Response(Status.INTERNAL_SERVER_ERROR); + } + + ValidationReport responseValidationReport + = validator.validateResponse(validatorRequest.getPath(), validatorRequest.getMethod(), validatorResponse); + if(responseValidationReport.hasErrors()) { + logger.warn("upstream response does not match specification: {} {}", validatorEntry.getKey(), responseValidationReport); + return new Response(Status.BAD_GATEWAY); + } + return response; + }); + } + + if(requestValidationReport.getMessages().stream().allMatch(m -> MISSING_OPERATION_ERROR_CODES.contains(m.getKey()))) { + logger.debug("request does not match given validator: {} {}", validatorEntry.getKey(), requestValidationReport); + } else { + logger.warn("client request does not match specification: {} {}", validatorEntry.getKey(), requestValidationReport); + return Promises.newResultPromise(new Response(Status.BAD_REQUEST)); + } + } + logger.info("client request does not match any specified route"); + return Promises.newResultPromise(Responses.newNotFound()); + } + + @Override + public void onChanges(FileChangeSet changes) { + + for (File file : changes.getRemovedFiles()) { + try { + logger.info("removing swagger file: {}", file); + validators.remove(file.getName()); + } catch (Exception e) { + logger.error("An error occurred while handling the removed file '{}'", file.getAbsolutePath(), e); + } + } + + for (File file : changes.getAddedFiles()) { + try { + logger.info("loading swagger file: {}", file); + validators.put(file.getName(), OpenApiInteractionValidator.createFor(file.getAbsolutePath()).build()); + } catch (Exception e) { + logger.error("An error occurred while handling the added file '{}'", file.getAbsolutePath(), e); + } + } + + for (File file : changes.getModifiedFiles()) { + try { + logger.info("loading updated swagger file: {}", file); + validators.put(file.getName(), OpenApiInteractionValidator.createFor(file.getAbsolutePath()).build()); + } catch (Exception e) { + logger.error("An error occurred while handling the modified file '{}'", file.getAbsolutePath(), e); + } + } + } + + /** + * Stops this handler, shutting down and clearing all the managed routes. + */ + public void stop() { + validators.clear(); + } + + public static class Heaplet extends GenericHeaplet { + + private SwaggerDirectoryMonitor directoryMonitor; + private ScheduledFuture scheduledCommand; + private Duration scanInterval; + + + @Override + public Object create() throws HeapException { + File directory = config.get("directory").as(evaluatedWithHeapProperties()).as(file()); + if (directory == null) { + // By default, uses the config/routes from the environment + Environment env = heap.get(ENVIRONMENT_HEAP_KEY, Environment.class); + directory = new File(env.getConfigDirectory(), "swagger"); + } + this.directoryMonitor = new SwaggerDirectoryMonitor(directory); + this.scanInterval = config.get("scanInterval") + .as(evaluatedWithHeapProperties()) + .defaultTo("10 seconds").as(duration()); + + Handler defaultHandler = config.get("handler").as(requiredHeapObject(heap, Handler.class)); + + return new SwaggerRouter(defaultHandler); + } + + + @Override + public void start() throws HeapException { + Runnable command = () -> { + try { + directoryMonitor.monitor((SwaggerRouter) object); + } catch (Exception e) { + logger.error("An error occurred while scanning the directory", e); + } + }; + + command.run(); + + if (scanInterval != Duration.ZERO) { + ScheduledExecutorService scheduledExecutorService = + heap.get(SCHEDULED_EXECUTOR_SERVICE_HEAP_KEY, ScheduledExecutorService.class); + + scheduledCommand = scheduledExecutorService.scheduleAtFixedRate(command, + scanInterval.to(MILLISECONDS), + scanInterval.to(MILLISECONDS), + MILLISECONDS); + } + } + + @Override + public void destroy() { + if (scheduledCommand != null) { + scheduledCommand.cancel(true); + } + if (object != null) { + ((SwaggerRouter) object).stop(); + } + super.destroy(); + } + } + + private static com.atlassian.oai.validator.model.Request validatorRequestOf(@Nonnull final org.forgerock.http.protocol.Request request) throws IOException { + SimpleRequest.Builder builder = new SimpleRequest.Builder(request.getMethod(), request.getUri().getPath()); + if(request.getEntity().getBytes().length > 0) { + builder.withBody(request.getEntity().getBytes()); + } + + if (request.getHeaders() != null) { + request.getHeaders().asMapOfHeaders().forEach((key, value) -> builder.withHeader(key, value.getValues())); + if(request.getEntity().getBytes().length > 0 + && request.getHeaders().keySet().stream().noneMatch(k -> k.equalsIgnoreCase("Content-Type"))) { + builder.withHeader("Content-Type", "application/json"); + } + } + + List params = URLEncodedUtils.parse(request.getUri().asURI(), StandardCharsets.UTF_8); + + Map> paramsMap = params.stream() + .collect(Collectors.groupingBy( + NameValuePair::getName, + Collectors.mapping(NameValuePair::getValue, Collectors.toList()) + )); + paramsMap.forEach(builder::withQueryParam); + + return builder.build(); + } + + private static com.atlassian.oai.validator.model.Response validatorResponseOf(@Nonnull final org.forgerock.http.protocol.Response response) throws IOException { + final SimpleResponse.Builder builder = new SimpleResponse.Builder(response.getStatus().getCode()); + if(response.getEntity().getBytes().length > 0) { + builder.withBody(response.getEntity().getBytes()); + } + + if (response.getHeaders() != null) { + response.getHeaders().asMapOfHeaders().forEach((key, value) -> builder.withHeader(key, value.getValues())); + if(response.getEntity().getBytes().length > 0 + && response.getHeaders().keySet().stream().noneMatch(k -> k.equalsIgnoreCase("Content-Type"))) { + builder.withHeader("Content-Type", "application/json"); + } + } + return builder.build(); + } +} + diff --git a/openig-core/src/test/java/org/openidentityplatform/openig/handler/router/SwaggerRouterTest.java b/openig-core/src/test/java/org/openidentityplatform/openig/handler/router/SwaggerRouterTest.java new file mode 100644 index 000000000..57e644bf9 --- /dev/null +++ b/openig-core/src/test/java/org/openidentityplatform/openig/handler/router/SwaggerRouterTest.java @@ -0,0 +1,292 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package org.openidentityplatform.openig.handler.router; + +import org.forgerock.http.Handler; +import org.forgerock.http.protocol.Request; +import org.forgerock.http.protocol.Response; +import org.forgerock.http.protocol.Status; +import org.forgerock.openig.handler.router.DestroyDetectHandler; +import org.forgerock.openig.heap.HeapImpl; +import org.forgerock.openig.heap.Keys; +import org.forgerock.services.context.Context; +import org.forgerock.services.context.RootContext; +import org.forgerock.util.promise.Promises; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.net.URISyntaxException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.openig.Files.getRelativeDirectory; +import static org.forgerock.openig.heap.HeapUtilsTest.buildDefaultHeap; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SwaggerRouterTest { + + private static final String VALID_PET = "{ \"id\": 10, \"name\": \"Buddy\" }"; + + private static final String INVALID_PET_MISSING_NAME = + "{\n" + + " \"id\": 10,\n" + + " \"photoUrls\": [\"https://example.com\"],\n" + + " \"tags\": [{ \"id\": 1, \"name\": \"friendly\" }],\n" + + " \"status\": \"available\"\n" + + "}"; + + + private static final String INVALID_PETS_RESPONSE = + "[\n" + + " { \"id\": 10, \"status\": \"available\" },\n" + + " { \"id\": 11, \"status\": \"pending\" }\n" + + "]"; + + private static final String VALID_PETS_RESPONSE = "[]"; + + @Mock + private ScheduledExecutorService scheduledExecutorService; + + @Mock + private Handler testHandler; + + private SwaggerDirectoryMonitor directoryMonitor; + + private Context ctx; + + @BeforeMethod + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + File swagger = getRelativeDirectory(SwaggerRouterTest.class, "swagger"); + directoryMonitor = new SwaggerDirectoryMonitor(swagger); + + HeapImpl heap = buildDefaultHeap(); + heap.put(Keys.SCHEDULED_EXECUTOR_SERVICE_HEAP_KEY, scheduledExecutorService); + + ctx = new RootContext(); + } + + @AfterMethod + public void tearDown() { + DestroyDetectHandler.destroyed = false; + } + + @Test + public void getRequest_validRequest_returns200() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + stubUpstream(Status.OK, "application/json", VALID_PETS_RESPONSE); + + Request request = get("/pets"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.OK); + } + @Test + public void postRequest_validPetBody_returns201() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + stubUpstream(Status.CREATED, null, null); + + Request request = post("/pets", "application/json", VALID_PET); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.CREATED); + verify(testHandler).handle(ctx, request); + } + + @Test + public void postRequest_missingRequiredField_returns400() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + + Request request = post("/pets", "application/json", INVALID_PET_MISSING_NAME); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.BAD_REQUEST); + // The downstream handler must never be called when the request is invalid. + verify(testHandler, never()).handle(any(), any()); + } + + + @Test + public void postRequest_malformedJson_returns400() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + + Request request = post("/pets", "application/json", "not-json-at-all"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.BAD_REQUEST); + verify(testHandler, never()).handle(any(), any()); + } + + @Test + public void postRequest_wrongContentType_returns400() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + + Request request = post("/pets", "text/plain", VALID_PET); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.BAD_REQUEST); + verify(testHandler, never()).handle(any(), any()); + } + + + @Test + public void getRequest_upstreamReturnsInvalidBody_returns502() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + stubUpstream(Status.OK, "application/json", INVALID_PETS_RESPONSE); + + Request request = get("/pets"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.BAD_GATEWAY); + } + + @Test + public void getRequest_upstreamReturnsUndefinedStatusCode_returns502() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + stubUpstream(Status.INTERNAL_SERVER_ERROR, "application/json", "{ \"error\": \"boom\" }"); + + Request request = get("/pets"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.BAD_GATEWAY); + } + + @Test + public void getRequest_unknownPath_returns404() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + + Request request = get("/unknown-path"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.NOT_FOUND); + verify(testHandler, never()).handle(any(), any()); + } + + @Test + public void optionsRequest_methodNotInSpec_returns404() + throws URISyntaxException, ExecutionException, InterruptedException { + + SwaggerRouter router = buildRouter(); + + Request request = new Request(); + request.setMethod("OPTIONS"); + request.setUri("http://localhost:8080/pets"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.NOT_FOUND); + verify(testHandler, never()).handle(any(), any()); + } + + @Test + public void handle_noSpecsLoaded_returns404() + throws URISyntaxException, ExecutionException, InterruptedException { + + // Router is created but monitor.monitor() is NOT called, so no specs are loaded. + SwaggerRouter router = new SwaggerRouter(testHandler); + + Request request = get("/pets"); + + Response response = router.handle(ctx, request).get(); + + assertThat(response.getStatus()).isEqualTo(Status.NOT_FOUND); + verify(testHandler, never()).handle(any(), any()); + } + + + @Test + public void onChanges_addedFile_validatorIsRegistered() { + + // Start with a router that has no specs loaded. + SwaggerRouter router = new SwaggerRouter(testHandler); + assertThat(router.validators).isEmpty(); + + directoryMonitor.monitor(router); + + assertThat(router.validators).isNotEmpty(); + } + + + private SwaggerRouter buildRouter() { + SwaggerRouter router = new SwaggerRouter(testHandler); + directoryMonitor.monitor(router); + return router; + } + + private void stubUpstream(Status status, String contentType, String body) { + when(testHandler.handle(any(Context.class), any(Request.class))) + .thenAnswer(invocation -> { + Response resp = new Response(status); + if (contentType != null) { + resp.getHeaders().add("Content-Type", contentType); + } + if (body != null) { + resp.setEntity(body); + } + return Promises.newResultPromise(resp); + }); + } + + private static Request get(String path) throws URISyntaxException { + Request r = new Request(); + r.setMethod("GET"); + r.setUri("http://localhost:8080" + path); + return r; + } + + private static Request post(String path, String contentType, String body) + throws URISyntaxException { + Request r = new Request(); + r.setMethod("POST"); + r.setUri("http://localhost:8080" + path); + r.getHeaders().add("Content-Type", contentType); + r.setEntity(body); + return r; + } +} \ No newline at end of file diff --git a/openig-core/src/test/resources/org/openidentityplatform/openig/handler/router/swagger/petstore.yaml b/openig-core/src/test/resources/org/openidentityplatform/openig/handler/router/swagger/petstore.yaml new file mode 100644 index 000000000..c19816dd8 --- /dev/null +++ b/openig-core/src/test/resources/org/openidentityplatform/openig/handler/router/swagger/petstore.yaml @@ -0,0 +1,120 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + maximum: 100 + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Pets' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + diff --git a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc index c15561a88..acea6768a 100644 --- a/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc +++ b/openig-doc/src/main/asciidoc/reference/handlers-conf.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024-2025 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -1312,3 +1312,138 @@ See also xref:expressions-conf.adoc#Expressions[Expressions(5)]. link:{apidocs-url}/index.html?org/forgerock/openig/handler/StaticResponseHandler.html[org.forgerock.openig.handler.StaticResponseHandler, window=\_blank] +[#SwaggerRouter] +=== SwaggerRouter — validate requests and responses against OpenAPI specifications + +[#swagger-router-description] +==== Description + +A `SwaggerRouter` is a handler that validates incoming HTTP requests and outgoing upstream +responses against one or more OpenAPI (Swagger) specification files loaded from a monitored +directory. + +On each incoming request, every loaded specification is evaluated in turn: + +* If the request matches a specification without errors, it is forwarded to the configured +upstream handler. The upstream response is then validated against the same specification. +A response that does not conform to the specification is replaced with +`502 Bad Gateway`. + +* If the request matches a path and method defined in a specification but violates that +specification's constraints (for example, a missing required field, a wrong type, or a +disallowed value), OpenIG returns `400 Bad Request` without forwarding the request. + +* If no loaded specification recognises the request's path or HTTP method, the next +specification is tried. When all specifications have been exhausted without a match, +OpenIG returns `404 Not Found`. + +Specification files are loaded from a directory that is monitored for changes. Files can be +added, removed, or modified at runtime without restarting the gateway. The directory is +rescanned at a configurable interval (default: 10 seconds). + +[#swagger-router-usage] +==== Usage + +[source, javascript] +---- +{ + "name": string, + "type": "SwaggerRouter", + "config": { + "handler": Handler reference, + "directory": expression, + "scanInterval": duration string + } +} +---- + +[#swagger-router-properties] +==== Properties +-- + +`"handler"`: __Handler reference, required__:: +The upstream handler to which spec-compliant requests are forwarded. ++ +Provide either the name of a Handler object defined in the heap, or an inline Handler +configuration object. ++ +See also xref:handlers-conf.adoc#handlers-conf[Handlers]. + +`"directory"`: __expression, optional__:: +Path to the directory from which OpenAPI specification files (`.yaml` or `.json`) are loaded. ++ +All files present in the directory are loaded as independent validators. A request is matched +against each validator in turn; the first validator that accepts the request without a +path-or-method error is used for full validation and dispatch. ++ +Default: `config/swagger` under the OpenIG configuration directory. ++ +See also xref:expressions-conf.adoc#Expressions[Expressions(5)]. + +`"scanInterval"`: __duration string, optional__:: +How often OpenIG rescans the directory for added, modified, or removed specification files, +expressed as a duration. ++ +include::../partials/sec-duration-description.adoc[] ++ +Set to `0` to perform a single scan at startup and disable periodic rescanning thereafter. ++ +Default: `10 seconds` + +-- + +[#swagger-router-hot-reload] +==== Hot Reload of Specification Files + +The `SwaggerRouter` monitors its configured directory at the interval set by `scanInterval`. +Changes take effect on the next scan without requiring a restart. + +If a specification file cannot be parsed, the error is logged and the file is skipped; all +other files in the directory continue to serve requests normally. + +[#swagger-router-example] +==== Example + +The following object configures a `SwaggerRouter` that loads OpenAPI specifications from +`/config/swagger`, forwards matching requests to `ClientHandler`, and rescans the directory +every 2 seconds: + +[source, json] +---- +{ + "name": "SwaggerRouter", + "type": "SwaggerRouter", + "config": { + "handler": "ClientHandler", + "directory": "/config/swagger", + "scanInterval": "2 seconds" + } +} +---- + +[#swagger-router-errors] +==== Error Responses + +`400 Bad Request`:: +The incoming request was matched to a known path and method in one of the loaded +specifications, but its body, headers, or query parameters did not satisfy the +specification's constraints (for example, a required field was absent, a value was of the +wrong type, or an enum value was not allowed). ++ +The request is rejected without contacting the upstream handler. + +`404 Not Found`:: +No loaded specification recognised the request's path or HTTP method. This response is also +returned when the `directory` is empty or no specification files have been loaded yet. + +`500 Internal Server Error`:: +An unexpected I/O error occurred while reading the request body or the upstream response +body during validation. + +`502 Bad Gateway`:: +The upstream handler returned a response whose body, headers, or status code do not conform +to the matched specification. + +[#swagger-router-javadoc] +==== Javadoc +link:{apidocs-url}/org/openidentityplatform/openig/handler/router/SwaggerRouter.html[org.openidentityplatform.openig.handler.router.SwaggerRouter, window=\_blank] From bdf2a597152a5bc87a3938bbbeb13ec433603386 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Fri, 13 Mar 2026 09:23:41 +0300 Subject: [PATCH 2/2] bootstrap-tabdrop url --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 547b2e4d0..f4214b464 100644 --- a/pom.xml +++ b/pom.xml @@ -376,7 +376,7 @@ 1.0 js - http://www.eyecon.ro/bootstrap-tabdrop/js/bootstrap-tabdrop.js + https://raw.githubusercontent.com/jmschabdach/bootstrap-tabdrop/master/js/bootstrap-tabdrop.js