diff --git a/openig-core/pom.xml b/openig-core/pom.xml
index bc8667b78..9c757ece8 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 2025-2026 3A Systems LLC.
-->
4.0.0
@@ -161,6 +161,13 @@
commons-iocommons-io
+
+ com.atlassian.oai
+ swagger-request-validator-core
+ 2.46.0
+
+
+
org.glassfish.grizzlygrizzly-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..ddd121df7 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
@@ -36,6 +36,7 @@
import org.forgerock.openig.filter.HeaderFilter;
import org.forgerock.openig.filter.HttpBasicAuthFilter;
import org.forgerock.openig.filter.LocationHeaderFilter;
+import org.forgerock.openig.filter.OpenApiValidationFilter;
import org.forgerock.openig.filter.PasswordReplayFilterHeaplet;
import org.forgerock.openig.filter.ScriptableFilter;
import org.forgerock.openig.filter.SqlAttributesFilter;
@@ -101,6 +102,7 @@ public class CoreClassAliasResolver implements ClassAliasResolver {
ALIASES.put("KeyStore", KeyStoreHeaplet.class);
ALIASES.put("LocationHeaderFilter", LocationHeaderFilter.class);
ALIASES.put("MappedThrottlingPolicy", MappedThrottlingPolicyHeaplet.class);
+ ALIASES.put("OpenApiValidationFilter", OpenApiValidationFilter.class);
ALIASES.put("PasswordReplayFilter", PasswordReplayFilterHeaplet.class);
ALIASES.put("Router", RouterHandler.class);
ALIASES.put("RouterHandler", RouterHandler.class);
diff --git a/openig-core/src/main/java/org/forgerock/openig/el/Functions.java b/openig-core/src/main/java/org/forgerock/openig/el/Functions.java
index 5a1bbe11c..eb2489f07 100644
--- a/openig-core/src/main/java/org/forgerock/openig/el/Functions.java
+++ b/openig-core/src/main/java/org/forgerock/openig/el/Functions.java
@@ -13,6 +13,7 @@
*
* Copyright 2010-2011 ApexIdentity Inc.
* Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems LLC.
*/
package org.forgerock.openig.el;
@@ -33,6 +34,7 @@
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
+import org.apache.commons.lang3.StringEscapeUtils;
import org.forgerock.http.util.Uris;
import org.forgerock.openig.util.StringUtil;
import org.forgerock.util.encode.Base64;
@@ -441,4 +443,14 @@ public static String fileToUrl(File file) {
}
}
+ /**
+ * Escapes the characters in a {@code String} using JSON string rules.
+ *
+ * @param value the string to escape, may be null
+ * @return a JSON escaped string
+ */
+ public static String escapeJson(String value) {
+ return StringEscapeUtils.escapeJson(value);
+ }
+
}
diff --git a/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java
new file mode 100644
index 000000000..953ec3528
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/filter/OpenApiValidationFilter.java
@@ -0,0 +1,273 @@
+/*
+ * 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.filter;
+
+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.Filter;
+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.json.JsonValue;
+import org.forgerock.openig.heap.GenericHeaplet;
+import org.forgerock.openig.heap.HeapException;
+import org.forgerock.services.context.AttributesContext;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import static org.forgerock.openig.util.JsonValues.optionalHeapObject;
+
+/**
+ * Validates HTTP requests and responses against an
+ * OpenAPI (Swagger 2.x / OpenAPI 3.x) specification
+ *
+ *
Request validation
+ *
If the request fails validation the filter stops processing and delegates to
+ * {@code requestValidationErrorHandler} instead of forwarding the request downstream.
+ * The default {@code requestValidationErrorHandler} returns {@code 400 Bad Request}.
+ *
+ *
Response validation
+ *
After the downstream handler returns a response, the filter validates it against the spec.
+ * Behaviour depends on {@code failOnResponseViolation}:
+ *
+ *
{@code true} – delegate to {@code responseValidationErrorHandler}. The default returns
+ * {@code 503 Service Unavailable}
+ *
{@code false} (default) – log a warning and pass the original response through.
+ */
+public class OpenApiValidationFilter implements Filter {
+
+ /**
+ * Key under which the {@link ValidationReport} is stored in the
+ * {@link AttributesContext} before delegating to an error handler.
+ */
+ public static final String ATTR_OPENAPI_VALIDATION_REPORT = "openApiValidationReport";
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenApiValidationFilter.class);
+
+ private final OpenApiInteractionValidator validator;
+
+ private final boolean failOnResponseViolation;
+
+ private final Handler requestValidationErrorHandler;
+
+ private final Handler responseValidationErrorHandler;
+
+ /**
+ * Creates a filter backed by a pre-built {@link OpenApiInteractionValidator}.
+ *
+ * @param spec The OpenAPI / Swagger specification to use in the validator
+ * @param failOnResponseViolation if {@code true}, a response validation failure results in
+ * a {@code 503} error; if {@code false}, it is only logged
+ * @param requestValidationErrorHandler handler invoked on request validation failure
+ * @param responseValidationErrorHandler handler invoked on response validation failure when
+ * {@code failOnResponseViolation} is {@code true}
+ */
+ private OpenApiValidationFilter(String spec, boolean failOnResponseViolation,
+ Handler requestValidationErrorHandler, Handler responseValidationErrorHandler) {
+ this(OpenApiInteractionValidator.createForInlineApiSpecification(spec).build(), failOnResponseViolation,
+ requestValidationErrorHandler, responseValidationErrorHandler);
+ }
+
+ OpenApiValidationFilter(OpenApiInteractionValidator validator, boolean failOnResponseViolation) {
+ this(validator, failOnResponseViolation,
+ defaultRequestValidationErrorHandler(), defaultResponseValidationErrorHandler());
+ }
+
+ OpenApiValidationFilter(OpenApiInteractionValidator validator, boolean failOnResponseViolation,
+ Handler requestValidationErrorHandler, Handler responseValidationErrorHandler) {
+ this.validator = validator;
+ this.failOnResponseViolation = failOnResponseViolation;
+ this.requestValidationErrorHandler = requestValidationErrorHandler;
+ this.responseValidationErrorHandler = responseValidationErrorHandler;
+ }
+
+ @Override
+ public Promise filter(Context context, Request request, Handler next) {
+
+ final SimpleRequest validatorRequest;
+ try {
+ validatorRequest = validatorRequestOf(request);
+ } catch (IOException e) {
+ logger.error("exception while reading the request", e);
+ return Promises.newResultPromise(new Response(Status.INTERNAL_SERVER_ERROR));
+ }
+
+ final ValidationReport requestReport = validator.validateRequest(validatorRequest);
+ if (requestReport.hasErrors()) {
+
+ logger.info("Request validation failed for {} {}: {}",
+ request.getMethod(), request.getUri(), requestReport);
+ return requestValidationErrorHandler.handle(injectReportToContext(context, requestReport), request);
+ }
+
+ return next.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", e);
+ 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: {}", responseValidationReport);
+ if(failOnResponseViolation) {
+ try {
+ return responseValidationErrorHandler.handle(
+ injectReportToContext(context, responseValidationReport), request)
+ .get();
+ } catch (InterruptedException | ExecutionException e) {
+ logger.error("exception while handling the response", e);
+ return new Response(Status.INTERNAL_SERVER_ERROR);
+ }
+ }
+ }
+ return response;
+ });
+ }
+
+ private static Context injectReportToContext(final Context parent, final ValidationReport report) {
+ Context context = parent;
+ if(!parent.containsContext(AttributesContext.class)) {
+ context = new AttributesContext(parent);
+ }
+ context.asContext(AttributesContext.class).getAttributes().put(ATTR_OPENAPI_VALIDATION_REPORT, report.getMessages());
+ return context;
+ }
+
+ private static Response buildErrorResponse(final Status status, final String body) {
+ final Response response = new Response(status);
+ response.getHeaders().put("Content-Type", "text/plain; charset=UTF-8");
+ response.setEntity(body);
+ return response;
+ }
+
+ private static SimpleRequest validatorRequestOf(final 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 SimpleResponse validatorResponseOf(final 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();
+ }
+
+ public static Handler defaultRequestValidationErrorHandler() {
+ return (context, request) ->
+ Promises.newResultPromise(buildErrorResponse(Status.BAD_REQUEST,
+ "Request validation failed: " + context.asContext(AttributesContext.class)
+ .getAttributes().get(ATTR_OPENAPI_VALIDATION_REPORT).toString()));
+ }
+
+ public static Handler defaultResponseValidationErrorHandler() {
+ return (context, request) ->
+ Promises.newResultPromise(buildErrorResponse(Status.SERVICE_UNAVAILABLE,
+ "Response validation failed: " + context.asContext(AttributesContext.class)
+ .getAttributes().get(ATTR_OPENAPI_VALIDATION_REPORT).toString()));
+ }
+
+ public static class Heaplet extends GenericHeaplet {
+
+ @Override
+ public Object create() throws HeapException {
+
+ JsonValue evaluatedConfig = config.as(evaluatedWithHeapProperties());
+ final String openApiSpec = evaluatedConfig.get("spec").required().asString();
+
+ final boolean failOnResponseViolation =
+ evaluatedConfig.get("failOnResponseViolation").defaultTo(false).asBoolean();
+
+ Handler requestValidationErrorHandler = config.get("requestValidationErrorHandler")
+ .as(optionalHeapObject(heap, Handler.class));
+ requestValidationErrorHandler = requestValidationErrorHandler == null ? defaultRequestValidationErrorHandler() : requestValidationErrorHandler;
+
+ Handler responseValidationErrorHandler = config.get("responseValidationErrorHandler")
+ .as(optionalHeapObject(heap, Handler.class));
+ responseValidationErrorHandler = responseValidationErrorHandler == null ? defaultResponseValidationErrorHandler() : responseValidationErrorHandler;
+
+ return new OpenApiValidationFilter(openApiSpec, failOnResponseViolation,
+ requestValidationErrorHandler, responseValidationErrorHandler);
+
+ }
+ }
+}
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..6f48e8770 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,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;
@@ -85,7 +86,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());
+ this(directory, new HashMap<>());
}
/**
@@ -177,15 +178,14 @@ FileChangeSet createFileChangeSet() {
/**
* Factory method to be used as a fluent {@link FileFilter} declaration.
*
- * @return a filter for {@literal .json} files
+ * @return a filter for {@literal .json and .yaml} files
*/
private static FileFilter jsonFiles() {
- return new FileFilter() {
- @Override
- public boolean accept(final File path) {
- return path.isFile() && path.getName().endsWith(".json");
- }
- };
+ return path -> path.isFile() && (
+ path.getName().endsWith(".json")
+ || path.getName().endsWith(".yaml")
+ || path.getName().endsWith(".yml")
+ );
}
void store(String routeId, JsonValue routeConfig) throws IOException {
diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiRouteBuilder.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiRouteBuilder.java
new file mode 100644
index 000000000..982a7f88c
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiRouteBuilder.java
@@ -0,0 +1,276 @@
+/*
+ * 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 io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.servers.Server;
+import org.forgerock.json.JsonValue;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.forgerock.json.JsonValue.json;
+
+/**
+ * Converts a parsed {@link OpenAPI} model into an OpenIG route {@link JsonValue}.
+ *
+ *
The generated route:
+ *
+ *
Has a {@code name} derived from {@code info.title} (slugified) or the spec filename stem.
+ *
Has a {@code condition} expression built from every {@code (path, method)} pair declared
+ * in the spec. Path parameter placeholders such as {@code {id}} are converted to the
+ * regex {@code [^/]+}, and each clause also checks {@code request.method} against the
+ * HTTP verbs declared for that path.
+ *
Has a {@code heap} containing a single {@code OpenApiValidationFilter} heap object
+ * pointing at the spec file.
+ *
Has a {@code handler} that is a {@code Chain} with the validation filter followed by a
+ * {@code ClientHandler} that proxies to the first server URL declared in the spec.
+ *
+ *
+ *
The generated route uses the {@code baseURI} decorator if a server URL is found in the spec
+ * so that all requests are forwarded to the upstream service.
+ */
+public class OpenApiRouteBuilder {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenApiRouteBuilder.class);
+
+ /** Name used to reference the validation filter inside the heap. */
+ private static final String VALIDATOR_HEAP_NAME = "OpenApiValidator";
+
+ /**
+ * Builds an OpenIG route {@link JsonValue} for the supplied OpenAPI specification.
+ *
+ * @param spec the parsed OpenAPI model
+ * @param specFile the original spec file on disk (used for the validator config and as a
+ * fallback route name)
+ * @param failOnResponseViolation if {@code true}, the generated
+ * {@code OpenApiValidationFilter} will return
+ * {@code 502 Bad Gateway} when a response violates the spec;
+ * if {@code false} (default), violations are only logged
+ * @return a {@link JsonValue} that can be passed directly to the {@code RouterHandler}'s
+ * internal route-loading mechanism
+ */
+
+ public JsonValue buildRouteJson(final OpenAPI spec, final File specFile, boolean failOnResponseViolation) {
+ final String routeName = deriveRouteName(spec, specFile);
+ final String condition = buildConditionExpression(spec);
+ final String baseUri = extractBaseUri(spec);
+
+ logger.info("Building OpenAPI route '{}' from spec file '{}' (condition: {}, baseUri: {}, failOnResponseViolation: {})",
+ routeName, specFile.getName(), condition, baseUri != null ? baseUri : "", failOnResponseViolation);
+
+
+ // ----- heap: one OpenApiValidationFilter entry -----
+ final Map validatorConfig = new LinkedHashMap<>();
+ validatorConfig.put("spec", "${read('" + specFile.getAbsolutePath() + "')}");
+ validatorConfig.put("failOnResponseViolation", failOnResponseViolation);
+
+ final Map validatorHeapObject = new LinkedHashMap<>();
+ validatorHeapObject.put("name", VALIDATOR_HEAP_NAME);
+ validatorHeapObject.put("type", "OpenApiValidationFilter");
+ validatorHeapObject.put("config", validatorConfig);
+
+ // ----- handler: Chain -> [OpenApiValidationFilter] -> ClientHandler -----
+ final Map chainConfig = new LinkedHashMap<>();
+ chainConfig.put("filters", List.of(VALIDATOR_HEAP_NAME));
+ chainConfig.put("handler", "ClientHandler");
+
+ final Map handlerObject = new LinkedHashMap<>();
+ handlerObject.put("type", "Chain");
+ handlerObject.put("config", chainConfig);
+
+ // ----- assemble root route object -----
+ final Map routeMap = new LinkedHashMap<>();
+ routeMap.put("name", routeName);
+
+ if (condition != null) {
+ routeMap.put("condition", condition);
+ }
+
+ // Apply baseURI decorator when the spec declares a server URL
+ if (baseUri != null) {
+ routeMap.put("baseURI", baseUri);
+ }
+
+ routeMap.put("heap", List.of(validatorHeapObject));
+ routeMap.put("handler", handlerObject);
+
+ return json(routeMap);
+ }
+
+ /**
+ * Produces a URL-safe route name from {@code info.title}, falling back to the filename stem.
+ */
+ private String deriveRouteName(final OpenAPI spec, final File specFile) {
+ String title = null;
+ if (spec.getInfo() != null && spec.getInfo().getTitle() != null) {
+ title = spec.getInfo().getTitle().trim();
+ }
+ if (title == null || title.isEmpty()) {
+ // Use the filename without extension
+ final String fileName = specFile.getName();
+ final int dot = fileName.lastIndexOf('.');
+ title = dot > 0 ? fileName.substring(0, dot) : fileName;
+ }
+ // Slugify: lowercase, replace non-alphanumeric (except hyphen) with hyphen, collapse runs
+ return title.toLowerCase()
+ .replaceAll("[^a-z0-9\\-]", "-")
+ .replaceAll("-{2,}", "-")
+ .replaceAll("^-|-$", "");
+ }
+
+ private String buildConditionExpression(final OpenAPI spec) {
+ if (spec.getPaths() == null || spec.getPaths().isEmpty()) {
+ return null; // catch-all
+ }
+
+
+ String baseUri = extractBaseUri(spec);
+ final String basePath;
+ if (baseUri != null) {
+ try {
+ basePath = new URI(baseUri).getPath();
+ } catch (URISyntaxException e) {
+ logger.warn("error parsing base URI: {}", e.toString());
+ return null;
+ }
+ } else {
+ basePath = "";
+ }
+
+ final List clauses = new ArrayList<>();
+
+ spec.getPaths().forEach((rawPath, pathItem) -> {
+ if (pathItem == null) {
+ return;
+ }
+ final String pathRegex = pathToRegex(basePath.concat(rawPath));
+ final Set methods = extractMethods(pathItem);
+
+ if (methods.isEmpty()) {
+ // Path is declared but has no operations — match the path regardless of method
+ clauses.add("matches(request.uri.path, '" + pathRegex + "')");
+ } else {
+ for (final String method : methods) {
+ clauses.add(
+ "(matches(request.uri.path, '" + pathRegex + "')"
+ + " && matches(request.method, '^" + method + "$'))");
+ }
+ }
+ });
+
+ if (clauses.isEmpty()) {
+ return null;
+ }
+ if (clauses.size() == 1) {
+ return "${" + clauses.get(0) + "}";
+ }
+ // Multi-clause: wrap each on its own line for readability, joined with ||
+ return "${" + String.join("\n || ", clauses) + "}";
+ }
+
+ /**
+ * Converts an OpenAPI path template to an anchored Java regex string.
+ *
+ *
+ *
+ * @param openApiPath the raw OpenAPI path template, e.g. {@code /pets/{petId}}
+ * @return an anchored regex string suitable for use inside {@code matches()} EL calls
+ */
+ static String pathToRegex(final String openApiPath) {
+ if (openApiPath == null || openApiPath.isEmpty()) {
+ return "^/$";
+ }
+ String regex = openApiPath;
+ // 1. Escape literal regex metacharacters that can appear in paths
+ regex = regex.replace(".", "\\.");
+ regex = regex.replace("+", "\\+");
+ // 2. Replace every {paramName} placeholder with a non-slash segment matcher
+ regex = regex.replaceAll("\\{[^/{}]+}", "[^/]+");
+ // 3. Anchor
+ return "^" + regex + "$";
+ }
+
+ /**
+ * Returns the set of HTTP method names (uppercase) for which the given {@link PathItem}
+ * has an operation defined. The set preserves insertion order.
+ *
+ *
Only the standard HTTP verbs recognised by the OpenAPI 3.x spec are considered:
+ * GET, PUT, POST, DELETE, OPTIONS, HEAD, PATCH, TRACE.
+ *
+ * @param pathItem an OpenAPI path item
+ * @return ordered set of method names, e.g. {@code ["GET", "POST"]}
+ */
+ static Set extractMethods(final PathItem pathItem) {
+ final Set methods = new LinkedHashSet<>();
+ if (pathItem.getGet() != null) methods.add("GET");
+ if (pathItem.getPut() != null) methods.add("PUT");
+ if (pathItem.getPost() != null) methods.add("POST");
+ if (pathItem.getDelete() != null) methods.add("DELETE");
+ if (pathItem.getOptions() != null) methods.add("OPTIONS");
+ if (pathItem.getHead() != null) methods.add("HEAD");
+ if (pathItem.getPatch() != null) methods.add("PATCH");
+ if (pathItem.getTrace() != null) methods.add("TRACE");
+ return methods;
+ }
+
+ /**
+ * Returns the first server URL from the spec (trimming trailing slashes), or {@code null}
+ * if no servers are declared.
+ */
+ private String extractBaseUri(final OpenAPI spec) {
+ if (spec.getServers() == null || spec.getServers().isEmpty()) {
+ return null;
+ }
+ final Server server = spec.getServers().get(0);
+ if (server.getUrl() == null || server.getUrl().isBlank()
+ || server.getUrl().equals("/")) {
+ return null;
+ }
+ // Remove trailing slash
+ String url = server.getUrl().trim();
+ if (url.endsWith("/")) {
+ url = url.substring(0, url.length() - 1);
+ }
+ return url;
+ }
+
+}
diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiSpecLoader.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiSpecLoader.java
new file mode 100644
index 000000000..6c37d8bb5
--- /dev/null
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/OpenApiSpecLoader.java
@@ -0,0 +1,121 @@
+/*
+ * 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 com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.parser.OpenAPIParser;
+import io.swagger.util.ObjectMapperFactory;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.parser.core.models.ParseOptions;
+import io.swagger.v3.parser.core.models.SwaggerParseResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * Detects whether a file in the routes directory is an OpenAPI spec (Swagger 2.x or OpenAPI 3.x)
+ * in either JSON or YAML format, and parses it into an {@link OpenAPI} model.
+ *
+ *
Detection logic:
+ *
+ *
File extension must be {@code .json}, {@code .yaml}, or {@code .yml}.
+ *
The parsed root object must contain an {@code openapi} key (OAS 3.x) or
+ * a {@code swagger} key (Swagger 2.x).
+ *
+ *
+ *
Normal OpenIG route JSON files contain a {@code handler} or {@code name} root key but
+ * not {@code openapi}/{@code swagger}, so they are not matched.
+ */
+public class OpenApiSpecLoader {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenApiSpecLoader.class);
+
+ private static final String EXT_JSON = ".json";
+
+ private final static ObjectMapper JSON_MAPPER = ObjectMapperFactory.createJson();
+ private final static ObjectMapper YAML_MAPPER = ObjectMapperFactory.createYaml();
+
+ public Optional tryLoad(final File file) {
+ if (!isOpenApiFile(file)) {
+ return Optional.empty();
+ }
+ try {
+ final ParseOptions parseOptions = new ParseOptions();
+ parseOptions.setResolve(true);
+ parseOptions.setResolveFully(true);
+
+ // Use the file URI so that relative $ref paths are resolved correctly
+ final String fileUri = file.toURI().toString();
+ final SwaggerParseResult result = new OpenAPIParser().readLocation(fileUri, null, parseOptions);
+
+ if (result == null || result.getOpenAPI() == null) {
+ logger.warn("Failed to parse OpenAPI spec from {}: parser returned null", file.getName());
+ return Optional.empty();
+ }
+ if (result.getMessages() != null && !result.getMessages().isEmpty()) {
+ result.getMessages().forEach(msg ->
+ logger.warn("OpenAPI parse warning for {}: {}", file.getName(), msg));
+ if(result.getMessages().stream().anyMatch(m -> m.toLowerCase().contains("exception"))) {
+ return Optional.empty();
+ }
+ }
+ logger.info("Successfully loaded OpenAPI spec from {}", file.getName());
+ return Optional.of(result.getOpenAPI());
+ } catch (Exception e) {
+ logger.error("Error loading OpenAPI spec from {}: {}", file.getName(), e.getMessage(), e);
+ return Optional.empty();
+ }
+ }
+
+
+ /**
+ * Returns {@code true} if the given file has a supported extension AND its root document
+ * contains an {@code openapi} or {@code swagger} key, meaning it looks like an OpenAPI spec
+ * rather than a regular OpenIG route configuration.
+ *
+ * @param file the file to test
+ * @return {@code true} if the file appears to be an OpenAPI specification
+ */
+ public boolean isOpenApiFile(final File file) {
+ if (file == null || !file.isFile()) {
+ return false;
+ }
+ try {
+ final JsonNode root = parseRootNode(file);
+ if (root == null || !root.isObject()) {
+ return false;
+ }
+ return root.has("openapi") || root.has("swagger");
+ } catch (IOException e) {
+ logger.debug("Could not probe file {} for OpenAPI markers: {}", file.getName(), e.getMessage());
+ return false;
+ }
+ }
+
+ private JsonNode parseRootNode(final File file) throws IOException {
+ final String name = file.getName().toLowerCase();
+ if (name.endsWith(EXT_JSON)) {
+ return JSON_MAPPER.readTree(file);
+ } else {
+ return YAML_MAPPER.readTree(file);
+ }
+ }
+}
diff --git a/openig-core/src/main/java/org/forgerock/openig/handler/router/RouterHandler.java b/openig-core/src/main/java/org/forgerock/openig/handler/router/RouterHandler.java
index 0cc4ccfea..59adfad18 100644
--- a/openig-core/src/main/java/org/forgerock/openig/handler/router/RouterHandler.java
+++ b/openig-core/src/main/java/org/forgerock/openig/handler/router/RouterHandler.java
@@ -12,12 +12,14 @@
* 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 java.lang.String.format;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.forgerock.json.JsonValue.object;
import static org.forgerock.json.JsonValueFunctions.duration;
import static org.forgerock.json.JsonValueFunctions.file;
import static org.forgerock.json.resource.Resources.newHandler;
@@ -35,11 +37,12 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
-import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.SortedSet;
import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@@ -47,6 +50,7 @@
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
+import io.swagger.v3.oas.models.OpenAPI;
import org.forgerock.http.Handler;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
@@ -82,6 +86,10 @@
* "directory": "/tmp/routes",
* "defaultHandler": "404NotFound",
* "scanInterval": 2 or "2 seconds"
+ * "openApiValidation": {
+ * "enabled": true,
+ * "failOnResponseViolation": false
+ * }
* }
* }
* }
@@ -95,6 +103,9 @@
* synchronously.
*
* In both cases, the default value is 10 seconds.
+ *
+ *
In addition to regular route JSON files, this handler now also recognises OpenAPI spec files
+ * ({@code .json}, {@code .yaml}, {@code .yml}) dropped into the same routes directory.
*
* @since 2.2
*/
@@ -112,6 +123,20 @@ public class RouterHandler implements FileChangeListener, Handler {
*/
private final DirectoryMonitor directoryMonitor;
+
+ /** Detects and parses OpenAPI spec files from the routes directory. */
+ private final OpenApiSpecLoader openApiSpecLoader;
+
+ /** Converts a parsed {@link OpenAPI} model into an OpenIG route {@link JsonValue}. */
+ private final OpenApiRouteBuilder openApiRouteBuilder;
+
+ private final OpenApiValidationSettings openApiValidationSettings;
+
+ /**
+ * Maps each OpenAPI spec {@link File} to the route ID that was generated for it.
+ */
+ private final Map openApiRouteIds = new ConcurrentHashMap<>();
+
/**
* Keep track of managed routes.
*/
@@ -144,11 +169,21 @@ public class RouterHandler implements FileChangeListener, Handler {
* @param directoryMonitor the directory monitor
*/
public RouterHandler(final RouteBuilder builder, final DirectoryMonitor directoryMonitor) {
+ this(builder, directoryMonitor, new OpenApiSpecLoader(), new OpenApiRouteBuilder(), new OpenApiValidationSettings());
+ }
+
+ protected RouterHandler(final RouteBuilder builder, final DirectoryMonitor directoryMonitor,
+ final OpenApiSpecLoader openApiSpecLoader, OpenApiRouteBuilder openApiRouteBuilder,
+ final OpenApiValidationSettings openApiValidationSettings) {
this.builder = builder;
this.directoryMonitor = directoryMonitor;
ReadWriteLock lock = new ReentrantReadWriteLock();
this.read = lock.readLock();
this.write = lock.writeLock();
+
+ this.openApiSpecLoader = openApiSpecLoader;
+ this.openApiRouteBuilder = openApiRouteBuilder;
+ this.openApiValidationSettings = openApiValidationSettings;
}
/**
@@ -305,12 +340,7 @@ JsonValue unload(String routeId) throws RouterHandlerException {
logger.info("Unloaded the route with id '{}'", routeId);
}
- Iterator iterator = sorted.iterator();
- while (iterator.hasNext()) {
- if (removedRoute == iterator.next()) {
- iterator.remove();
- }
- }
+ sorted.removeIf(route -> removedRoute == route);
return removedRoute.getConfig();
} finally {
write.unlock();
@@ -392,6 +422,41 @@ public void onChanges(FileChangeSet changes) {
}
private void onAddedFile(File file) {
+ if(openApiValidationSettings.enabled && openApiSpecLoader.isOpenApiFile(file)) {
+ loadOpenApiSpec(file);
+ } else {
+ loadRouteFile(file);
+ }
+ }
+
+ /**
+ * Synthesises and loads a route from an OpenAPI spec file.
+ * If a route was previously loaded from the same spec file (e.g. on a hot-reload),
+ * the old route is unloaded first.
+ */
+ private void loadOpenApiSpec(final File specFile) {
+ logger.info("Loading OpenAPI spec file: {}", specFile.getName());
+ final Optional specOpt = openApiSpecLoader.tryLoad(specFile);
+ if (specOpt.isEmpty()) {
+ logger.warn("Skipping OpenAPI spec {} – could not be parsed", specFile.getName());
+ return;
+ }
+
+ final JsonValue routeJson = openApiRouteBuilder.buildRouteJson(
+ specOpt.get(), specFile, openApiValidationSettings.failOnResponseViolation);
+ final String routeId = routeJson.get("name").asString();
+
+ try {
+ load(routeId, routeId, routeJson);
+ openApiRouteIds.put(specFile, routeId);
+ logger.info("OpenAPI route '{}' loaded successfully from {}", routeId, specFile.getName());
+ } catch (Exception e) {
+ logger.error("Failed to load route for OpenAPI spec {}: {}",
+ specFile.getName(), e.getMessage(), e);
+ }
+ }
+
+ private void loadRouteFile(File file) {
try {
JsonValue routeConfig = readJson(file.toURI().toURL());
String routeId = routeId(file);
@@ -406,9 +471,16 @@ private void onAddedFile(File file) {
private void onRemovedFile(File file) {
try {
- unload(routeId(file));
+ final String routeId;
+ if (openApiRouteIds.containsKey(file)) {
+ routeId = openApiRouteIds.remove(file);
+ logger.info("OpenAPI spec removed: {}; unloading route '{}'", file.getName(), routeId);
+ } else {
+ routeId = routeId(file);
+ logger.info("Route file removed: {}; unloading route '{}'", file.getName(), routeId);
+ }
+ unload(routeId);
} catch (RouterHandlerException e) {
- // No route with id routeId was found. Just ignore.
logger.warn("The file '{}' has not been loaded yet, removal ignored.", file, e);
}
}
@@ -447,10 +519,21 @@ public Object create() throws HeapException {
this.scanInterval = scanInterval();
EndpointRegistry registry = endpointRegistry();
- RouterHandler handler = new RouterHandler(new RouteBuilder((HeapImpl) heap,
- qualified,
- registry),
- directoryMonitor);
+
+ final JsonValue oaConfig = config.get("openApiValidation").defaultTo(object());
+ final boolean openApiEnabled = oaConfig.get("enabled").defaultTo(true).asBoolean();
+ final boolean failOnResponseViolation = oaConfig.get("failOnResponseViolation")
+ .defaultTo(false).asBoolean();
+ final OpenApiValidationSettings openApiValidationSettings =
+ new OpenApiValidationSettings(openApiEnabled, failOnResponseViolation);
+
+ final RouteBuilder routeBuilder = new RouteBuilder((HeapImpl) heap, qualified, registry);
+
+ final OpenApiSpecLoader openApiSpecLoader = openApiEnabled ? new OpenApiSpecLoader() : new DisabledOpenApiSpecLoader();
+
+ RouterHandler handler = new RouterHandler(routeBuilder, directoryMonitor, openApiSpecLoader,
+ new OpenApiRouteBuilder(), openApiValidationSettings);
+
handler.setDefaultHandler(config.get("defaultHandler").as(optionalHeapObject(heap, Handler.class)));
RunMode mode = heap.get(RUNMODE_HEAP_KEY, RunMode.class);
@@ -462,6 +545,9 @@ public Object create() throws HeapException {
"frapi:openig:router-handler")));
logger.info("Routes endpoint available at '{}'", registration.getPath());
}
+
+
+
return handler;
}
@@ -491,14 +577,11 @@ private Duration scanInterval() {
@Override
public void start() throws HeapException {
- Runnable command = new Runnable() {
- @Override
- public void run() {
- try {
- directoryMonitor.monitor((RouterHandler) object);
- } catch (Exception e) {
- logger.error("An error occurred while scanning the directory", e);
- }
+ Runnable command = () -> {
+ try {
+ directoryMonitor.monitor((RouterHandler) object);
+ } catch (Exception e) {
+ logger.error("An error occurred while scanning the directory", e);
}
};
@@ -531,4 +614,39 @@ public void destroy() {
super.destroy();
}
}
+
+ public static final class OpenApiValidationSettings {
+
+ public final boolean enabled;
+
+ public final boolean failOnResponseViolation;
+
+
+ public OpenApiValidationSettings(final boolean enabled,
+ final boolean failOnResponseViolation) {
+ this.enabled = enabled;
+ this.failOnResponseViolation = failOnResponseViolation;
+ }
+
+ public OpenApiValidationSettings() {
+ this(true, false);
+ }
+ }
+
+ /**
+ * A no-op {@link OpenApiSpecLoader} that never matches any file.
+ * Used when OpenAPI validation is disabled in the heaplet config.
+ */
+ private static class DisabledOpenApiSpecLoader extends OpenApiSpecLoader {
+ @Override
+ public boolean isOpenApiFile(final File file) {
+ return false;
+ }
+
+ @Override
+ public Optional tryLoad(final File file) {
+ return Optional.empty();
+ }
+ }
+
}
diff --git a/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java b/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java
new file mode 100644
index 000000000..1a3888709
--- /dev/null
+++ b/openig-core/src/test/java/org/forgerock/openig/filter/OpenApiValidationFilterTest.java
@@ -0,0 +1,230 @@
+/*
+ * 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.filter;
+
+import com.atlassian.oai.validator.OpenApiInteractionValidator;
+import com.atlassian.oai.validator.report.ValidationReport;
+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.json.JsonValue;
+import org.forgerock.json.JsonValueException;
+import org.forgerock.openig.heap.HeapImpl;
+import org.forgerock.openig.heap.Name;
+import org.forgerock.services.context.Context;
+import org.forgerock.services.context.RootContext;
+import org.forgerock.util.promise.Promises;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.forgerock.json.JsonValue.json;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class OpenApiValidationFilterTest {
+
+ private OpenApiInteractionValidator mockValidator;
+ private Handler mockNextHandler;
+ private Context rootContext;
+
+ @BeforeMethod
+ public void setUp() throws IOException {
+ mockValidator = mock(OpenApiInteractionValidator.class);
+ mockNextHandler = mock(Handler.class);
+ rootContext = new RootContext();
+ }
+
+ @Test
+ public void filter_forwardsRequest_whenRequestAndResponseAreValid() throws Exception {
+ when(mockValidator.validateRequest(any())).thenReturn(ValidationReport.empty());
+ when(mockValidator.validateResponse(any(), any(), any())).thenReturn(ValidationReport.empty());
+
+ final Response upstreamResponse = new Response(Status.OK);
+ upstreamResponse.setEntity("hello");
+ when(mockNextHandler.handle(any(), any()))
+ .thenReturn(Promises.newResultPromise(upstreamResponse));
+
+ final OpenApiValidationFilter filter = new OpenApiValidationFilter(mockValidator, false);
+ final Response response = filter.filter(rootContext, buildGetRequest("http://localhost/pets"),
+ mockNextHandler).get();
+
+ assertThat(response.getStatus()).isEqualTo(Status.OK);
+ assertThat(response.getEntity().getString()).isEqualTo("hello");
+ }
+
+ @Test
+ public void filter_returns400_whenRequestValidationFails() throws Exception {
+ when(mockValidator.validateRequest(any()))
+ .thenReturn(singleErrorReport("Request body is required"));
+
+ final OpenApiValidationFilter filter = new OpenApiValidationFilter(mockValidator, false);
+ final Response response = filter.filter(rootContext, buildGetRequest("http://localhost/pets"),
+ mockNextHandler).get();
+
+ assertThat(response.getStatus()).isEqualTo(Status.BAD_REQUEST);
+ assertThat(response.getEntity().getString()).contains("Request validation failed");
+ assertThat(response.getEntity().getString()).contains("Request body is required");
+
+ verify(mockNextHandler, never()).handle(any(), any());
+ }
+
+ @Test
+ public void filter_passesResponse_whenResponseValidationFailsAndFlagIsFalse() throws Exception {
+ when(mockValidator.validateRequest(any())).thenReturn(ValidationReport.empty());
+ when(mockValidator.validateResponse(any(), any(), any()))
+ .thenReturn(singleErrorReport("Response missing required field"));
+
+ final Response upstreamResponse = new Response(Status.OK);
+ upstreamResponse.setEntity("{\"x\":1}");
+ when(mockNextHandler.handle(any(), any()))
+ .thenReturn(Promises.newResultPromise(upstreamResponse));
+
+ final OpenApiValidationFilter filter = new OpenApiValidationFilter(mockValidator, false);
+ final Response response = filter.filter(rootContext, buildGetRequest("http://localhost/pets"),
+ mockNextHandler).get();
+
+ // Response passes through despite the validation error (log-only mode)
+ assertThat(response.getStatus()).isEqualTo(Status.OK);
+ }
+
+ @Test
+ public void filter_returns503_whenResponseValidationFailsAndFlagIsTrue() throws Exception {
+ when(mockValidator.validateRequest(any())).thenReturn(ValidationReport.empty());
+ when(mockValidator.validateResponse(any(), any(), any()))
+ .thenReturn(singleErrorReport("Response schema mismatch"));
+
+ final Response upstreamResponse = new Response(Status.OK);
+ upstreamResponse.setEntity("{\"x\":1}");
+ when(mockNextHandler.handle(any(), any()))
+ .thenReturn(Promises.newResultPromise(upstreamResponse));
+
+ final OpenApiValidationFilter filter = new OpenApiValidationFilter(mockValidator, true);
+ final Response response = filter.filter(rootContext, buildGetRequest("http://localhost/pets"),
+ mockNextHandler).get();
+
+ assertThat(response.getStatus()).isEqualTo(Status.SERVICE_UNAVAILABLE);
+ assertThat(response.getEntity().getString()).contains("Response validation failed");
+ assertThat(response.getEntity().getString()).contains("Response schema mismatch");
+ }
+
+ @Test
+ public void filter_preservesRequestBodyForDownstream() throws Exception {
+ when(mockValidator.validateRequest(any())).thenReturn(ValidationReport.empty());
+ when(mockValidator.validateResponse(any(), any(), any())).thenReturn(ValidationReport.empty());
+
+ final String requestBody = "{\"name\":\"Fido\"}";
+ AtomicReference capturedBody = new AtomicReference<>();
+
+ when(mockNextHandler.handle(any(), any())).thenAnswer(inv -> {
+ final Request req = inv.getArgument(1);
+ capturedBody.set(req.getEntity().getString());
+ final Response r = new Response(Status.CREATED);
+ r.setEntity("{}");
+ return Promises.newResultPromise(r);
+ });
+
+ final OpenApiValidationFilter filter = new OpenApiValidationFilter(mockValidator, false);
+ filter.filter(rootContext, buildPostRequest("http://localhost/pets", requestBody),
+ mockNextHandler).get();
+
+ assertThat(capturedBody.get()).isEqualTo(requestBody);
+ }
+
+ @Test
+ public void filter_preservesResponseBodyForCaller() throws Exception {
+ when(mockValidator.validateRequest(any())).thenReturn(ValidationReport.empty());
+ when(mockValidator.validateResponse(any(), any(), any())).thenReturn(ValidationReport.empty());
+
+ final String body = "{\"id\":42,\"name\":\"Fido\"}";
+ final Response upstreamResponse = new Response(Status.OK);
+ upstreamResponse.setEntity(body);
+ when(mockNextHandler.handle(any(), any()))
+ .thenReturn(Promises.newResultPromise(upstreamResponse));
+
+ final OpenApiValidationFilter filter = new OpenApiValidationFilter(mockValidator, false);
+ final Response response = filter.filter(rootContext,
+ buildGetRequest("http://localhost/pets/42"), mockNextHandler).get();
+
+ assertThat(response.getEntity().getString()).isEqualTo(body);
+ }
+
+ @Test(expectedExceptions = JsonValueException.class)
+ public void heaplet_throwsHeapException_whenSpecFileDoesNotExist() throws Exception {
+ final org.forgerock.openig.heap.HeapImpl heap = new HeapImpl(Name.of("test"));
+ final JsonValue config = json(
+ org.forgerock.json.JsonValue.object(
+ org.forgerock.json.JsonValue.field("spec",
+ "${read('/nonexistent/path/spec.yaml')}")));
+
+ new OpenApiValidationFilter.Heaplet().create(Name.of("testFilter"), config, heap);
+ }
+
+ @Test
+ public void heaplet_createsFilter_withValidSpecFile() throws Exception {
+ final String spec =
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: Test\n"
+ + " version: '1'\n"
+ + "paths:\n"
+ + " /items:\n"
+ + " get:\n"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: OK\n";
+
+ final org.forgerock.openig.heap.HeapImpl heap = new HeapImpl(Name.of("test"));
+ final org.forgerock.json.JsonValue config = org.forgerock.json.JsonValue.json(
+ org.forgerock.json.JsonValue.object(
+ org.forgerock.json.JsonValue.field("spec", spec),
+ org.forgerock.json.JsonValue.field("failOnResponseViolation", true)));
+
+ final Object created = new OpenApiValidationFilter.Heaplet()
+ .create(Name.of("testFilter"), config, heap);
+
+ assertThat(created).isInstanceOf(OpenApiValidationFilter.class);
+ }
+
+ private static Request buildGetRequest(final String uri) throws Exception {
+ final Request r = new Request();
+ r.setMethod("GET");
+ r.setUri(uri);
+ return r;
+ }
+
+ private static Request buildPostRequest(final String uri, final String body) throws Exception {
+ final Request r = new Request();
+ r.setMethod("POST");
+ r.setUri(uri);
+ r.getHeaders().put("Content-Type", "application/json");
+ r.setEntity(body);
+ return r;
+ }
+
+ private static ValidationReport singleErrorReport(final String message) {
+ return ValidationReport.singleton(
+ ValidationReport.Message.create("test.error.key", message).build());
+ }
+}
diff --git a/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java
new file mode 100644
index 000000000..83454db5e
--- /dev/null
+++ b/openig-core/src/test/java/org/forgerock/openig/handler/router/OpenApiRouteBuilderTest.java
@@ -0,0 +1,329 @@
+/*
+ * 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 io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import org.forgerock.json.JsonValue;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class OpenApiRouteBuilderTest {
+
+ private File tempDir;
+
+ private OpenApiSpecLoader specLoader;
+ private OpenApiRouteBuilder routeBuilder;
+
+ @BeforeMethod
+ public void setUp() throws IOException {
+ tempDir = Files.createTempDirectory("openig-route-builder-test").toFile();
+
+ specLoader = new OpenApiSpecLoader();
+ routeBuilder = new OpenApiRouteBuilder();
+ }
+
+ @DataProvider(name = "pathToRegexCases")
+ public static Object[][] pathToRegexCases() {
+ return new Object[][] {
+ { "/pets", "^/pets$" },
+ { "/pets/{id}", "^/pets/[^/]+$" },
+ { "/pets/{petId}/photos", "^/pets/[^/]+/photos$" },
+ { "/v1/{org}/{repo}/releases", "^/v1/[^/]+/[^/]+/releases$" },
+ { "/a.b/{x}", "^/a\\.b/[^/]+$" },
+ { "/items/{id+}", "^/items/[^/]+$" },
+ { "/users", "^/users$" },
+ };
+ }
+
+ @Test(dataProvider = "pathToRegexCases")
+ public void pathToRegex_convertsTemplateToAnchoredRegex(
+ final String input, final String expected) {
+ assertThat(OpenApiRouteBuilder.pathToRegex(input)).isEqualTo(expected);
+ }
+
+ @Test
+ public void pathToRegex_producedRegex_matchesConcreteUrls() {
+ final String regex = OpenApiRouteBuilder.pathToRegex("/pets/{id}");
+ assertThat("/pets/42".matches(regex)).isTrue();
+ assertThat("/pets/fluffy-cat".matches(regex)).isTrue();
+ // Must NOT match the collection endpoint or sub-paths
+ assertThat("/pets".matches(regex)).isFalse();
+ assertThat("/pets/42/photos".matches(regex)).isFalse();
+ }
+
+ @Test
+ public void extractMethods_returnsOnlyDefinedMethods() {
+ final PathItem item = new PathItem();
+ item.setGet(new Operation());
+ item.setPost(new Operation());
+
+ final Set methods = OpenApiRouteBuilder.extractMethods(item);
+ assertThat(methods).containsExactly("GET", "POST");
+ }
+
+
+ @Test
+ void buildRouteJson_usesSlugifiedInfoTitle_asRouteName() throws Exception {
+ final File spec = writeYaml("petstore.yaml",
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: 'Pet Store API'\n"
+ + " version: '1.0.0'\n"
+ + "paths: {}\n");
+
+ final JsonValue route = build(spec);
+ assertThat(route.get("name").asString()).isEqualTo("pet-store-api");
+ }
+
+ @Test
+ public void buildRouteJson_fallsBackToFileStem_whenTitleIsAbsent() throws IOException {
+ final File spec = writeYaml("my-openapi-spec.yaml",
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " version: '1.0.0'\n"
+ + "paths: {}\n");
+ final JsonValue route = build(spec);
+ assertThat(route.get("name").asString()).isEqualTo("my-openapi-spec");
+ }
+
+ @Test
+ public void buildRouteJson_hasNoCondition_whenSpecHasNoPaths() throws IOException {
+ final File spec = writeYaml("empty-paths.yaml",
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: Empty\n"
+ + " version: '1'\n"
+ + "paths: {}\n");
+
+ assertThat(build(spec).get("condition").isNull()).isTrue();
+ }
+
+ @Test
+ public void buildRouteJson_conditionContainsExactPathRegex() throws IOException {
+ final File spec = writeYaml("single.yaml", specWithPaths("/pets"));
+ final String condition = build(spec).get("condition").asString();
+ assertThat(condition).contains("^/pets$");
+ }
+
+ @Test
+ public void buildRouteJson_conditionContainsMethodCheck() throws IOException {
+ final File spec = writeYaml("single.yaml", specWithPaths("/pets"));
+ final String condition = build(spec).get("condition").asString();
+ assertThat(condition).containsIgnoringCase("request.method");
+ assertThat(condition).contains("GET");
+ }
+
+ @Test
+ public void buildRouteJson_paramInPath_isConvertedToNonSlashRegex() throws IOException {
+ final File spec = writeYaml("param.yaml",
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: Test\n"
+ + " version: '1'\n"
+ + "paths:\n"
+ + " /pets/{petId}:\n"
+ + " get:\n"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: OK\n");
+
+ final String condition = build(spec).get("condition").asString();
+ assertThat(condition).contains("[^/]+");
+ assertThat(condition).doesNotContain("{petId}");
+ }
+
+ @Test
+ public void buildRouteJson_multiplePathParamsAreAllConverted() throws IOException {
+ final File spec = writeYaml("multi-param.yaml",
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: Test\n"
+ + " version: '1'\n"
+ + "paths:\n"
+ + " /orgs/{org}/repos/{repo}:\n"
+ + " get:\n"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: OK\n");
+
+ final String condition = build(spec).get("condition").asString();
+ assertThat(countOccurrences(condition, "[^/]+")).isEqualTo(2);
+ }
+
+ @Test
+ public void buildRouteJson_multipleMethodsForSamePath_generateSeparateClauses()
+ throws IOException {
+ final File spec = writeYaml("multi-method.yaml",
+ "openapi: '3.0.3'\n"
+ + "info:\n"
+ + " title: Test\n"
+ + " version: '1'\n"
+ + "paths:\n"
+ + " /pets:\n"
+ + " get:\n"
+ + " responses:\n"
+ + " '200':\n"
+ + " description: OK\n"
+ + " post:\n"
+ + " responses:\n"
+ + " '201':\n"
+ + " description: Created\n");
+
+ final String condition = build(spec).get("condition").asString();
+ assertThat(condition).contains("GET");
+ assertThat(condition).contains("POST");
+
+ assertThat(countOccurrences(condition, "^/pets$")).isEqualTo(2);
+ }
+
+ @Test
+ public void buildRouteJson_multipleDistinctPaths_useOrOperator() throws IOException {
+ final File spec = writeYaml("multi-path.yaml", specWithPaths("/users", "/orders"));
+ final String condition = build(spec).get("condition").asString();
+ assertThat(condition).contains("^/users$");
+ assertThat(condition).contains("^/orders$");
+ assertThat(condition).contains("||");
+ }
+
+ @Test
+ public void buildRouteJson_heapContainsOpenApiValidationFilter() throws IOException {
+ final File spec = writeYaml("petstore.yaml", specWithPaths("/pets"));
+ final List